在2021年6月份,基于混合包的方案,我们首次上架了京东App鸿蒙版本,截止目前,我们已经累计发布了13个版本,带有鸿蒙特性的业务模块包括了搜索、商详、京东活动中心、领券中心、支付码、直播。而且还有很多业务方正在规划适配鸿蒙的原子化特性。
目前京东App鸿蒙版的代码工程结构使用的是官方推荐的方式,即一个Entry module 加多个Feature module的模块化开发形式。
每个Feature module隶属于Entry,可以独立编译运行也可以打包整体运行。这个是现有的鸿蒙应用工程结构,这块就不详细介绍了。
随着业务模块接入的越来越多,涉及到各个部门的开发人员也越来越多,大家都在一个工程中进行开发,很多问题也随之而来:
1. 整个工程编译时间越来越长;
2. 其他模块配置错误或者编译失败影响整个App的编译,甚至影响整个App的发版节奏;
3. 开发语言不一样,有用Java的,有用JS的,每个人的环境配置不同也会导致编译失败;
4. 增加或减少一个模块由于修改了主工程的配置,需要做完整的回归测试;
5. 各个模块没有代码仓库的隔离,存在多人修改同一个模块,出现代码冲突
上面提到的这些问题,我们在Android 工程发展的过程中也曾遇到,为了解决这些问题,Android组件化、插件化开始盛行。我们看一下京东App 插件化方案是如何实现的。
目前在京东App中,一个工程的插件化是通过Aura来实现的,Aura 是伴随着京东商城不断发展而衍生出来的一个平台,它包含了插件化框架的实现、周边配套的编译平台、后台管理平台等,为Android开发提供了一体化的解决方案。
几个概念:
公共库:是一个aar工程,用来存放组件和宿主共用的类和资源。
插件:插件工程是一个独立的工程,编译产物可以运行在宿主环境中。
宿主:主工程,提供运行环境。
插件化工程也是一个标准的Android工程,包含applicaton 模块,和library模块,一个标准的插件工程如下:
bundle-demoA ├── application // Andorid Applicaiton module └── build.gradle ├── library // Android Liarary module └── build.gradle ├── build.gradle ├── gradle.properties ├── local.properties └── settings.gradle
插件工程打出的包是一个apk,上传到maven仓库,宿主的配置文件会保存所有插件的版本信息,插件的版本会在插件打包期间进行更新,宿主在编译期间会去maven仓库将对应版本的插件包下载到特定目录下,从而实现插件的集成。
参考京东App 插件化的实现方案我们开始了对鸿蒙组件化方案的探索。
先介绍一下鸿蒙应用的编译产物结构,在Debug模式下,是一个entry.hap和多个feature.hap;在release模式下是一个.app文件,将其解压后里面仍然是一个entry.hap和多个feature.hap,但是多了一个pack.info和pack.res文件。
首先想到的是参考我们常见的Android App的组件化开发模式,将各个Feature module抽取到单独的代码工程中,然后编译成har包,供主工程依赖。这种方式虽然能解决上述问题,但是由于各个业务模块被构建成了har包,必须被其他app依赖才能运行,失去了鸿蒙的免安装、分布式等原子化特性。
另外一种方式参考京东App Android工程的插件化方案,将现有鸿蒙工程中的各个Feature module抽取成独立的代码工程,然后编译成.hap包,hap包可以独立安装运行仍然保持其原子特性。将各个hap包收集后统一放在主工程中,然后由主工程进行合并成一个完成的app。下图就是大致的思路:
首先,每个组件工程的Entry module不包含任务业务代码,并保持配置一样,将各自的业务代码都放在Feature module里。通过编译各个组件工程,可以获取到entry.hap和feature.hap,将各个feature.hap上传到Maven 私服上,主工程配置对组件的依赖,当主工程编译时将组件拉取到本地再加上主工程编译出的entry.hap这样应该就组成了Debug模式下的编译产物了,如果Debug模式的流程跑通,Release模式应该问题不大。大致流程如下:
根据上边设想的方案,我们将现有的搜索和活动日历业务模块抽离主工程,以独立工程的形式进行编译,并将编译产物上传私服。
在主工程依赖产物时,如果用普通的组件依赖方式,是无法将我们的业务组件拉取到指定目录。这里我们通过自定义gralde任务,实现对组件的依赖配置及拉取。大家可以参考下:
def downloadDep(String artifact) {
def map = [:]
//1 根据maven坐标创建依赖对象
Dependency dependency = project.dependencies.create(artifact)
//2 根据依赖创建一个配置,但是不添加到工程的配置里
Configuration configuration = project.configurations.detachedConfiguration(dependency)
//3 由于下载的是hap包,不涉及依赖传递,这里设置位false
configuration.setTransitive(false)
//4 这里会自动下载依赖的文件
configuration.files.each { file ->
if (file.isFile()) {
map['depFile'] = file
map['depName'] = dependency.getName()
} else {
println "Could not find the file corresponding to the artifact '$artifact'"
}
}
return map
}
//将依赖的组件包复制到指定目录下
def copyToTarget(File depFile, String depName) {
project.copy {
from depFile
into hapsDir
}
}
在主工程中进行依赖配置
ohoshap_dependencies {
artifact 'xxx.xxx.xxx:searchfeature:1.0.0'
artifact 'xxx.xxx.xxx:activityfeature:1.0.0'
}
将该任务进行相关的依赖配置,在主工程构建后进行组件的拉取,做到流程自动化。
当这些工作做完后,我们在主工程里执行assembleDebug后,可以看到在build目录下各个组件也都拉取到了。
然后我们通过hdc命令可以正常将各个hap安装,搜索和活动日历的卡片也能正常创建,正以为大功告成的时候,启动搜索卡片发现页面UI错乱了!
原本应该正常展示的图片和背景全都不对了,经过排查是资源id混乱了,而这个问题我们在Android插件化方案中已经解决了。
Android系统中资源ID是一个int值,是由三部分组成的:PackageId+TypeId+EntryId,分别占2字节,2字节,4字节。PackageId:是包的Id值,Android中如果是第三方应用的话,这个值默认就是0x7F,系统应用的话就是0x01。无论是插件还是宿主编译后的PackageId都是0x7F。TypeId:是资源的类型Id值,一般Android中有这几个类型:attr,drawable,layout,dimen,string,style等,而且这些类型的值是从1开始逐渐递增的,而且顺序不能改变,attr=0x01,drawable=0x02....
EntryId:是在具体的类型下资源实体的id值,从0开始,依次递增。
为了防止宿主和插件的资源冲突, Aura插件选择的方式是修改插件的PackageId,保证每个插件都分配有自己唯一的packageId,使用gradle插件方案,在android编译apk的一些关键task中使用hook方式干预插件编译的处理。
那么鸿蒙工程是不是也类似?由于鸿蒙的编译插件并不开源,我们只有去本地的.gralde缓存目录下去找到这个jar包,然后通过反编译分析一下了。
一般资源ID都是以0x开头,那我们就直接搜0x,在com.huawei.ohos.build.utils.FeatureUtils里找到了setFeaturePackageId方法。但是由于是反编译的,根本无法看明白逻辑。
public static void setFeaturePackageId(ProjectExtraInfo projectExtraInfo, Project project) { CallSite[] arrayOfCallSite = $getCallSiteArray(); if (!DefaultTypeTransformation.booleanUnbox(arrayOfCallSite[0].call(projectExtraInfo))) { return; } if (!DefaultTypeTransformation.booleanUnbox(arrayOfCallSite[1].call(projectExtraInfo))) { arrayOfCallSite[2].call(arrayOfCallSite[3] .callGetProperty(GlobalDataManager.class), arrayOfCallSite[4].callGetProperty(project) , arrayOfCallSite[5].call("0x", arrayOfCallSite[6] .call(Integer.class, arrayOfCallSite[7].callGetProperty(GlobalDataManager.class)))); Object object1; ScriptBytecodeAdapter.setProperty(arrayOfCallSite[9].call(object1 = arrayOfCallSite[8] .callGetProperty(GlobalDataManager.class)), null, GlobalDataManager.class, "START_FEATURE_PACKAGE_ID"); arrayOfCallSite[9].call(object1 = arrayOfCallSite[8].callGetProperty(GlobalDataManager.class)); } arrayOfCallSite[10].call(arrayOfCallSite[11].callGetProperty(GlobalDataManager.class) , arrayOfCallSite[12].call(arrayOfCallSite[13].call(arrayOfCallSite[14].callGetProperty(project), "_") , arrayOfCallSite[15].callGetProperty(BuildConst.class)) , arrayOfCallSite[16].call("0x", arrayOfCallSite[17].call(Integer.class , arrayOfCallSite[18].callGetProperty(GlobalDataManager.class)))); Object object; ScriptBytecodeAdapter.setProperty(arrayOfCallSite[20].call(object = arrayOfCallSite[19] .callGetProperty(GlobalDataManager.class)), null, GlobalDataManager.class, "START_FEATURE_PACKAGE_ID"); arrayOfCallSite[20].call(object = arrayOfCallSite[19].callGetProperty(GlobalDataManager.class)); }
虽然看似找到了关键方法,但是根本找不到调用处。方法参数里ProjectExtraInfo也没有定义资源ID相关的变量。但是可以看到GlobalDataManager这个类出现了好多次,在这里面应该可以发现关键点。
public class GlobalDataManager implements GroovyObject { private static GlobalDataManager globalDataManager; public static int START_FEATURE_PACKAGE_ID; static { Object object = $getCallSiteArray()[31].callConstructor(HashMap.class); featurePackageId = (Map<String, String>)ScriptBytecodeAdapter.castToType(object, Map.class); } public static Map<String, String> featurePackageId; ......}
静态变量featurePackageId看命名的含义应该是给各个feature工程分配的资源ID段。为了验证猜测,我们可以写一个任务,遍历看一下。
task test{ doLast { com.huawei.ohos.build.data.GlobalDataManager.featurePackageId.each { println("${it.key} ==> ${it.value}") } }}
通过打印可以看出,这个featurePackageId里key是每个project的名称,value是资源ID的起始位置;
START_FEATURE_PACKAGE_ID 这个变量我们找不到调用处,通过打印可以看到值是0x80。而featurePackageId里也都是从0x80开始递增的,这个字段应该是资源ID初始值。
我们尝试在编译前修改featurePackageId的值,为每个project重新分配资源ID,比如搜索组件改成0xA6,活动日历组件改成0xA5。再次编译安装后发现可以正常展示了。我们就可以写个资源ID修改的任务,在编译前先修改资源ID将流程串起来。到这里Debug模式下的业务组件拆分与集成目前看是没什么问题了,整体也算是比较简单。
一般我们使用工程根目录下的signReleaseApp命令进行构建Release包。其产物是.app文件,本质上是包含了多个.hap文件和一个pack.res文件一个pack.info文件的压缩包。
Release模式下的组件化实现思路和Debug模式类似,将各个业务组件构建出Release包后,然后将产物解压找到Feature module的hap包,然后根据Debug的流程将各个组件的hap集成到主工程中,只需要想办法生成pack.res和pack.info两个文件就可以了。
首先是将各个业务组件的.hap包上传私服的任务改造一下,在Debug模式中,发布任务是依赖的assembleDebug,现在需要根据不同的编译类型进行兼容处理下。
1. 在Release模式下我们新增一个解压任务,并依赖signReleaseApp,该任务要做的是要将构建出的.app进行解压到指定目录下
2. 发布任务依赖解压任务,在指定目录下找到feature模块的hap包并上传至私服即可。(文件名规则:[feature module名称]-entry-release-rich.hap)
注意:别忘了先配置每个组件的资源ID。
这样主工程就可以根据依赖配置在编译后获取到各个组件的hap包了。
使用原有的模式构建出一个app包,解压后找到pack.info并打开这个文件,可以看到这个文件是JSON结构,是对各个module配置文件的汇总。
包括了app的版本号、包名信息,modules节点下包含各个模块的配置信息,如支持的设备、安装配置信息、abilities组件信息,packages节点下包括各个模块的基本信息,如模块类型(entry\feature)、可支持的设备、安装包名称。
而每个hap包内,都包含一份pack.info文件,那么我们就可以从主工程中拉取到的组件产物以及主工程的编译产物中抽取pack.info相关内容合并成一个完整的pack.info文件。
我们最开始也不清楚这个文件到底是什么东西。我们尝试在执行打包命令的时候增加-i(./gradlew :signReleaseApp -i),看看能不能发现生成这个文件的相关任务参数。发现这个文件是通过hmos_app_packing_tool.jar生成的,关键参数如下。
java -jar /xxx/harmony/SDK/toolchains/lib/hmos_app_packing_tool.jar --mode res --pack-info-path /xxx/build/outputs/app/release/pack.info--entrycard-path /xxx/EntryCard --out-path /xxx/build/outputs/app/release/pack.res --force true
参数中用到的pack.info文件,是上一步任务生成的,EntryCard目录是用来存放服务卡片快照用的,当我们创建feature module时,如果选择了展示在服务中心,就会生成这个目录。
这段命令看样子是根据pack.info和EntryCard来生成pack.res文件。我们读一下该文件的前4个字节,获取到的是zip文件格式的文件头504b0304,解压后,发现其实就是压缩了EntryCard后命名为pack.res。
知道这个文件的真身之后就比较好办了。首先我们在主工程中手动创建EntryCard目录,将各个组件的卡片快照放在该目录下。然后在主工程编译结束后使用命令生成这个文件就可以了。
但是在开始编译主工程时报错了,原因是我们主工程内并没有相关的模块配置卡片,但是又存在卡片快照目录。看来这个EntryCard目录不能够随便创建,我们可以新建个jingdongEntryCard目录,其结构和EntryCard目录保持一致,并将各个组件的桌面快照放在这里,当主工程编译结束时进行重命名就好了。
这样我们的pack.res也生成了。
通过执行./gradlew :signReleaseApp -i,除了看出生成pack.res文件的命令外,还能看到生成未签名.app的命令。
java -jar /xxx/harmony/SDK/toolchains/lib/hmos_app_packing_tool.jar --mode app --hap-path a.hap,b.hap,c.hap,... --pack-info-path /xxx/pack.info --pack-res-path /xxx/pack.res --out-path /xxx/release-unsigned.app --force true
这个命令的含义就是根据各个hap包以及pack.info和pack.res文件来组装成一个未签名的app文件。由于我们已经有了各个hap包,并且也有了生成pack.info和pack.res文件的方法,我们按照这个命令执行就能够生成未签名的app包了。
使用SDK里自带的签名工具进行签名,命令如下:
java -jar /xxx/harmony/SDK/toolchains/lib/hmos_app_packing_tool.jar --mode app --hap-path a.hap,b.hap,c.hap,... --pack-info-path /xxx/pack.info --pack-res-path /xxx/pack.res --out-path /xxx/release-unsigned.app --force true
整个Release模式下的组件集成流程如下图所示:
我们编写了一套Gradle插件,包含了组件的打包发布、组件的拉取等相关任务,能够自动化的完成图上的各个步骤。
原有的工程结构,各个业务模块和主模块都在一个代码工程中,一起构建组成一个完整的App。
现有模式将各个业务模块拆分成独立组件代码工程,每个组件工程既可以独立运行也能够提供产物供主工程依赖,通过编译主工程获取一个完成的App。
在构建时间上:原有结构下11个module构建出app需要3分钟以上;经过组件化改造之后构建app需要35s左右。
将各个业务模块拆成单独工程之后,发版流程就和Android App保持一致了。各个业务组件的开发同学在测试通过后将自己的组件按时集成进主工程即可。
之前修改各个模块资源ID是通过反编译鸿蒙应用编译插件找到的线索,为了安全起见避免这块有纰漏,我们咨询了华为DevEco工具的研发人员,并将多仓库组件化的方案和遇到的问题进行了同步。果然,我们之前修改资源ID的方式是不全面的。通过详细沟通,华为方面给我们提供了可以配置资源ID的编译工具,并表示该方案会规划到DevEco的后续需求中去。让我们一起期待鸿蒙官方对多仓库组件化方案的支持吧。