本文作者:燒麥
當前國內各個公司 APP 出海創收已經是網際網路行業的常見操作。筆者最近約 2 年的時間裡,都在進行雲音樂旗下首個出海應用 Android 客戶端的開發。本文對海外 APP 一些開發經驗做一些分享。
初次出海的時候,我們總結了需要適配海外環境的方方面面,包括
客戶端內的很多通用模組需要支援海外環境。這裡包括
- 確認一些三方服務對於海外環境的支援程度,例如雲信、聲網 SDK
- 一些常見 APP 功能的海外版本封裝,例如登入,檔案上傳,推送,分享
- 底層庫功能自查,支援上架政策和一些資源配置。
我們的目的是,儘量保持原有的技術框架去開發新的 APP,不要因為運營環境變了,技術架構也大改。
- Android APP 的釋出渠道和釋出格式。海外 Android 應用以 Google Play 上架釋出為主,這裡我們需要額外支援 aab(android app bundle) 格式進行釋出。
海外應用設計
基礎庫海外實現層
基礎模組我們遵循介面實現分離的設計原則,以檔案上傳底層庫為例,我們會有3個最終打成 aar 的 module:
- uploader_interface 提供檔案上傳相關的各種介面
- uploader_module uploader_interface module各個介面的具體實現,例如檔案透過中臺的 CDN 介面上傳。
- uploader_module_oversea 同樣是 uploader_interface module裡面各個介面的具體實現,實現邏輯從直接 CDN 介面上傳改為先上傳至亞馬遜雲,然後把亞馬遜雲的上傳資訊同步給 CDN。
得益於上面的設計原則,基礎模組我們只需要提供對應的海外實現即可。業務程式碼內呼叫的仍然是介面 module 的 API,這樣做一來一些依賴底層的業務程式碼可以直接複用,二來開發同學也不需要再去熟悉另一套底層庫 API。
底層庫合規檢查
海外 APP 在 Google Play 作為主要分發渠道的情況下,隱私政策可能和國內略有不同。而一些底層庫可能包括了一些不合規的程式碼,這部分需要進行排查,一般來說,遵循下面 2 個原則就不容易出現問題:
- 底層庫程式碼裡面沒有違規的 API 呼叫,例如和熱修復這種動態程式碼下發的。Google Play 不允許相關功能
- 底層庫的依賴裡不要包含海外環境用不到的功能。例如一些之前全公司 APP 都通用的三方服務的SDK被整合在了某個底層庫,雖然海外沒有使用相關功能,但是這些 SDK 非常有可能因為包括了動態下發 so 而被檢查出來。
Google Play 隱私政策可以參考
https://support.google.com/googleplay/android-developer/answer/9888170?hl=zh-Hans&ref_topic=9877467
底層庫資源
另一方面,對於比較簡單的底層邏輯,我們一般情況也不會對其做介面與實現拆分,但是底層有可能會使用一些通用的資源,例如文案、圖示等。如果我們把這些值作為變數設定進去,一方面底層庫的改動比較大,另一方面初始化時候的設定也非常的繁瑣。這裡我們可以利用 Android 自身的資源合併策略。
如上圖,底層庫裡面定義的 key1 字串,我們在上層定義同名的字串 key2, 最終在打包的時候,資源合併會保留 key2。所以也需要我們在設計底層庫的時候避免直接使用字串硬編碼,以免不能靈活支援海外應用。
aab 檔案與 Play Store 分發
app bundle 格式
使用 app bundle 格式當下在 Google Play 進行分發是唯一選擇。
我們使用
./gradlew :app:bundleRelease
構建我們的 app bundle 檔案上傳至 Google Play 後臺進行釋出。
但是由於 aab 檔案並不能直接安裝在裝置上,所以在日常的測試、迴歸階段,我們仍然是安裝 apk 檔案來進行,流程如下圖:
從理論上來說,apk測試迴歸沒有什麼問題,aab 也就沒什麼問題。但是在日常實踐,我們可能會有一些 Gradle Plugin 的 task 在 hook 一些編譯任務的時候,忽略了 aab 的情況,從而導致一些執行時的錯誤。針對這種情況,在正式的 aab 檔案釋出前,我們還是有必要對其做一個快速的走查。
Google 官方也提供了方法讓我們安裝 aab 檔案到裝置上,使用 bundletool 工具根據 aab 檔案生成 apks 檔案,然後使用 adb install-multiple
命令安裝:
java -jar bundletool.jar build-apks --bundle=${FILE_NAME} --output=${target_apks}
unzip target_apks
cd splits
adb install-multiple bae-master.apk xx.apk
這樣測試迴歸流程則可以加上 aab,但是讓 qa 同學每次使用指令碼安裝總也是個麻煩的事情,所以能否更徹底點呢?答案當然是可以的,既然可以透過 install-multiple
安裝 apks 檔案,那麼 CI 流程上每次 aab 構建的時候,輸出 aab 和 apks 2個產物,然後透過一個安裝 apks 檔案的 APP 進行安裝。
我們可以透過 android.content.pm.PackageInstaller
這個 Android API 實現這個功能
程式碼如下:
val installer = InstallApp.application().packageManager.packageInstaller
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
val sessionId = installer.createSession(params)
val installSession = installer.openSession(sessionId)
apks.forEach {
installSession.openWrite(it.hashCode().toString(), 0, -1)
.use { out->
FileInputStream(it).use {fin->
val buffer = ByteArray(16384)
var len: Int
while (fin.read(buffer).also { len = it } != -1) {
out.write(buffer, 0, len)
}
}
installSession.fsync(out)
installSession.close()
}
}
val intent = Intent(InstallApp.application(), RetActivity::class.java)
intent.action = PACKAGE_INSTALLED_ACTION
val pendingIntent = PendingIntent.getActivity(InstallApp.application(), 0, intent, FLAG_MUTABLE)
val statusReceiver = pendingIntent.intentSender
installSession.commit(statusReceiver)
安裝結果我們可以透過 Intent 裡面的 android.content.pm.extra.STATUS
獲取。
這裡我們就可以不適用指令碼命令列,直接使用安裝工具安裝aab檔案,app 的迴歸釋出流程就比較完善了:
Google Play 簽名
Android 應用透過 Google Play 釋出的時候,還需要開啟 Google Play 應用簽名功能,具體的操作和規則可以參考 Play 管理中心文件:
https://support.google.com/googleplay/android-developer/answer/9842756。
按照官方圖示,Google Play 會把開發者上傳的金鑰重新簽名為新的金鑰進行釋出。
最終 Google Play 控制檯裡面會顯示最終的金鑰指紋和上傳金鑰指紋:
Google Play 之所以設計這套看起來有點複雜的秘鑰管理,是為了保障 APP 的簽名安全。當我們的上傳秘鑰出現被盜取或者丟失的情況下,也只需要申請重新替換上傳秘鑰即可。
但是我們的 APP 在釋出的時候,我們不僅需要在 Google Play 進行釋出,還需要釋出自己的 APK 渠道包。在後臺升級金鑰的時候,會有如下幾個選項
如果使用預設的 Google Play 生成新的金鑰,我們只能匯出一個字尾名為 der
的證照,這個證照裡面只包括了公鑰,所以即使同 keystore 工具匯出 jks 檔案,也不能正常打包。所以我們需要選擇 “從Java金鑰庫上傳新的應用簽名金鑰”
這裡還需要注意一點,選擇新的金鑰規則預設選擇 Android T 及以上版本升級,且此選項預設收起。我們需要選擇下面的 “所有Android版本的所有新安裝”,否則無法達到最終目的。
所有我們最終簽名流程如下圖所示:
我們擁有 2 個打包簽名檔案,分別為 release.jks 和 store.jks,透過 Google 的 pepk.jar 工具把 Google Play 的簽名換位 store.jks。最終在釋出的時候:
- aab 檔案使用 release.jks 構建,上傳後會重籤為 store.jks 釋出
- release 渠道包的apk檔案使用 store.jks 構建,這樣 apk 和商店下載的 aab 檔案簽名才一致,才能算是同一個 APP
Google Play 釋出問題
在使用 Google Play 釋出的時候,如果我們使用了 uses-feature
宣告功能的時候,最終在釋出的時候,可能會導致最終釋出後顯示支援裝置型別數為 0,這樣使用者將無法下載甚至無法在 Google Play上看到該版本。
我們需要在宣告的地方新增上 android:required="false"
即可。為了避免底層庫和上層的定義有矛盾導致 AndroidManifest 合併出錯,我們可以透過 Gradle 指令碼修改合併後的 AndroidManifest 檔案,把 reuqired 的值全部改為 true:
android.applicationVariants.all { variant ->
variant.outputs.each { output ->
def processManifest = output.getProcessManifestProvider().get()
processManifest.doLast { task ->
def outputDir = task.multiApkManifestOutputDirectory
File outputDirectory
if (outputDir instanceof File) {
outputDirectory = outputDir
} else {
outputDirectory = outputDir.get().asFile
}
File manifestOutFile = file("$outputDirectory/AndroidManifest.xml")
if (manifestOutFile.exists() && manifestOutFile.canRead() && manifestOutFile.canWrite()) {
def manifestPath = manifestOutFile
def xml = new XmlParser().parse(manifestPath)
def androidSpace = new Namespace('http://schemas.android.com/apk/res/android', 'android')
xml."uses-feature".each {it->
println it.attributes().get(androidSpace.name)
if (it.attributes()[androidSpace.name] == "android.hardware.camera.front" ||
it.attributes()[androidSpace.name] == 'android.hardware.camera.front.autofocus') {
it.attributes()[androidSpace.required] = false
}
}
PrintWriter pw = new PrintWriter(manifestPath)
def content = XmlUtil.serialize(xml)
println content
pw.write(content)
pw.close()
}
}
}
}
應用多語言
多語言工作流
提到應用出海,還有一個繞不開的話題就是應用多語言問題。 我們透過設定 Locale 來設定語言。並且在語言切換的時候重建 Activity:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
config.locale = target
res.updateConfiguration(config, res.displayMetrics)
config.setLocale(target)
context.createConfigurationContext(config)
} else {
config.locale = target
res.updateConfiguration(config, res.displayMetrics)
}
具體多語言我們會從內部的多語言平臺拉取打包後的xml檔案,放到對應的資料夾下。應用在 Locale 修改後會自動選擇對應語言的檔案。例如英文目錄為 /res/values-en
,印尼語為 /res/values-in
。流程如下圖:
隨著出海APP增多及運營國家支援語種增多,上述簡單的多語言匯入流程也逐漸的不夠使用,包括:
- 語言較多,並且定義在程式碼內,每次新增語言配置都需要各個使用的地方(例如註冊選擇語言,設定切換語言等)修改程式碼。配置化程度比較低。一旦漏改,就會存在bug。
- 從多語言平臺下載文案並放入res資料夾裡面的時候,需要有一個 values 資料夾作為預設語言文案,在開發階段,我們從互動稿上看到並且錄入的基本為中文,但是釋出後的預設文案應該為英文。如果全程手動操作非常繁瑣。
我們使用 Gradle 外掛來解決這2個問題。
- 每個應用支援的多語言型別透過配置檔案定義,Gradle 外掛根據配置檔案內容生成語言資訊的常量程式碼。
- 在編譯期新增一個自動拉取多語言的 task,註冊在
pre${variant}Build
task 之後。當 variant 屬於 debug 的時候,res/values 裡面放的為中文的xml檔案。當 variant 屬於 release 的時候,res/values 裡面放的為英文的xml檔案。
整個 language plugin 的工作如下:
其中,自動拉取外掛在替換文案之前,還可以做一次預檢查操作。防止因為翻譯錯誤等原因導致編譯報錯。例如
- 文案裡面檢查 $1 轉為 %1$s 的時候,是否有字元缺失或者增加了空字元導致 String.format 出錯
- 文案裡面存在 & 符號,需要修改為 &
多語言解耦
在 app 的日常維護中,時常會有多語言文案需要替換。在上述工作流中,非客戶端開發在需要替換文案的時候,需要頻繁的提問客戶端開發需要替換的具體 key。這樣無疑增加了需要溝通成本。我們還可以透過一些技術手段來減少這部分的耦合。
常見的文案的替換場景大概分為兩類
- 測試、走查階段發現某些語種存在翻譯缺失
- 開新區增加新翻譯的時候,某些語種的文案長度不合理需要精簡
這兩種場景,非開發角色不經過溝通並不知道具體的多語言 key 是什麼。
針對上述兩種情況,我們的多語言外掛設計了兩部分功能。
缺失文案檢查及 mock 文案生成
多語言外掛在文案拉取的時候,對平臺生成的多語言 xml 檔案進行分別檢查。當某語種中某個文案不存在的時候,會生成一個模擬的多語言文案寫入到xml檔案。模擬文案則會帶上這條文案的 key。
例如 key 為 common_hello 的文案在印尼語有缺失,那麼執行時切換到印尼語時使用的文案就是 mock 的文案 "客戶端mock common_hello(id)",這樣 qa 或者策劃看到就知道這裡缺失了一條文案翻譯。
執行時查詢多語言key
當 app 業務方開發新區的時候,我們也可以把查詢文案這件事儘可能的和技術剝離開。我們在 debug 執行時提供了一個懸浮窗工具,當工具開啟的時候,可以選擇當前頁面的 TextView,
如果這個 TextView 得內容是透過 string id 載入的,那麼就會把這個 key 顯示在螢幕上。具體效果如下圖:
這樣我們能節省開區過程中很大一部分查詢多語言 key 的溝通,增加開區效率。
展望與總結
這裡介紹了一些 Android APP 出海的實踐,涵蓋了技術框架設計,釋出流程,多語言等內容。並且對於大部分海外地區來說,Android 機型分佈比較混亂,低端機型較多,且網路環境較國內比較差。在啟動速度,記憶體管理、網路最佳化等方面,我們出海的 APP 還有很多需要建設的地方,希望能和大家進行分享交流。
本文釋出自網易雲音樂技術團隊,文章未經授權禁止任何形式的轉載。我們常年招收各類技術崗位,如果你準備換工作,又恰好喜歡雲音樂,那就加入我們 grp.music-fe(at)corp.netease.com!