京東APP鴻蒙工程元件化探索

語言: CN / TW / HK

京東App鴻蒙版現狀

在2021年6月份,基於混合包的方案,我們首次上架了京東App鴻蒙版本,截止目前,我們已經累計釋出了13個版本,帶有鴻蒙特性的業務模組包括了搜尋、商詳、京東活動中心、領券中心、支付碼、直播。而且還有很多業務方正在規劃適配鴻蒙的原子化特性。

0 1

現有工程結構

目前京東App鴻蒙版的程式碼工程結構使用的是官方推薦的方式,即一個Entry module 加多個Feature module的模組化開發形式。

每個Feature module隸屬於Entry,可以獨立編譯執行也可以打包整體執行。這個是現有的鴻蒙應用工程結構,這塊就不詳細介紹了。

0 2

面臨的問題

隨著業務模組接入的越來越多,涉及到各個部門的開發人員也越來越多,大家都在一個工程中進行開發,很多問題也隨之而來:

1. 整個工程編譯時間越來越長;

2. 其他模組配置錯誤或者編譯失敗影響整個App的編譯,甚至影響整個App的發版節奏;

3. 開發語言不一樣,有用Java的,有用JS的,每個人的環境配置不同也會導致編譯失敗;

4. 增加或減少一個模組由於修改了主工程的配置,需要做完整的迴歸測試;

5. 各個模組沒有程式碼倉庫的隔離,存在多人修改同一個模組,出現程式碼衝突

上面提到的這些問題,我們在Android 工程發展的過程中也曾遇到,為了解決這些問題,Android元件化、外掛化開始盛行。我們看一下京東App 外掛化方案是如何實現的。

京東App Android 外掛化方案

目前在京東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 外掛化的實現方案我們開始了對鴻蒙元件化方案的探索。

鴻蒙工程元件化

0 1

方案設想

先介紹一下鴻蒙應用的編譯產物結構,在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模式應該問題不大。大致流程如下:

02

方案實施

根據上邊設想的方案,我們將現有的搜尋和活動日曆業務模組抽離主工程,以獨立工程的形式進行編譯,並將編譯產物上傳私服。

在主工程依賴產物時,如果用普通的元件依賴方式,是無法將我們的業務元件拉取到指定目錄。這裡我們通過自定義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外掛化方案中已經解決了。

0 3

難點:資源的處理

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模式下的業務元件拆分與整合目前看是沒什麼問題了,整體也算是比較簡單。

04

正式版產物

一般我們使用工程根目錄下的signReleaseApp命令進行構建Release包。其產物是.app檔案,本質上是包含了多個.hap檔案和一個pack.res檔案一個pack.info檔案的壓縮包。

Release模式下的元件化實現思路和Debug模式類似,將各個業務元件構建出Release包後,然後將產物解壓找到Feature module的hap包,然後根據Debug的流程將各個元件的hap整合到主工程中,只需要想辦法生成pack.res和pack.info兩個檔案就可以了。

05

構建元件產物

首先是將各個業務元件的.hap包上傳私服的任務改造一下,在Debug模式中,釋出任務是依賴的assembleDebug,現在需要根據不同的編譯型別進行相容處理下。

1. 在Release模式下我們新增一個解壓任務,並依賴signReleaseApp,該任務要做的是要將構建出的.app進行解壓到指定目錄下

2. 釋出任務依賴解壓任務,在指定目錄下找到feature模組的hap包並上傳至私服即可。(檔名規則:[feature module名稱]-entry-release-rich.hap)

注意:別忘了先配置每個元件的資源ID。

這樣主工程就可以根據依賴配置在編譯後獲取到各個元件的hap包了。

06

生成pack.info

使用原有的模式構建出一個app包,解壓後找到pack.info並開啟這個檔案,可以看到這個檔案是JSON結構,是對各個module配置檔案的彙總。

包括了app的版本號、包名資訊,modules節點下包含各個模組的配置資訊,如支援的裝置、安裝配置資訊、abilities元件資訊,packages節點下包括各個模組的基本資訊,如模組型別(entry\feature)、可支援的裝置、安裝包名稱。

而每個hap包內,都包含一份pack.info檔案,那麼我們就可以從主工程中拉取到的元件產物以及主工程的編譯產物中抽取pack.info相關內容合併成一個完整的pack.info檔案。

07

生成pack.res

我們最開始也不清楚這個檔案到底是什麼東西。我們嘗試在執行打包命令的時候增加-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也生成了。

08

生成未簽名的app

通過執行./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包了。

09

對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的後續需求中去。讓我們一起期待鴻蒙官方對多倉庫元件化方案的支援吧。