当前国内各个公司 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 隐私政策可以参考
support.google.com/googleplay/…
底层库资源
另一方面,对于比较简单的底层逻辑,我们一般情况也不会对其做接口与实现拆分,但是底层有可能会使用一些通用的资源,例如文案、图标等。如果我们把这些值作为变量设置进去,一方面底层库的改动比较大,另一方面初始化时候的设置也非常的繁琐。这里我们可以利用 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 管理中心文档:
support.google.com/googleplay/…。
按照官方图示,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 的工作如下:
其中,自动拉取插件在替换文案之前,还可以做一次预检查操作。防止因为翻译错误等原因导致编译报错。例如
- 文案里面检查 <math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mn>1</mn><mtext>转为</mtext></mrow><annotation encoding="application/x-tex">1 转为 %1</annotation></semantics></math>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 还有很多需要建设的地方,希望能和大家进行分享交流。
作者:网易云音乐技术团队
网友评论