Android APP 出海實踐

語言: CN / TW / HK

本文作者:燒麥

當前國內各個公司 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 隱私政策可以參考

http://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 進行分發是唯一選擇。

我們使用

kotlin ./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 命令安裝:

kotlin 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 實現這個功能

代碼如下:

```kotlin 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 管理中心文檔:

http://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:

```kotlin 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:

kotlin 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!