用户规模较大的APP发版一般包含3个阶段,即灰度验证、A/B验证、全量发布。有时会根据具体情况减少部分或全部验证阶段直接进行全量发布。
灰度验证 通过使少量用户升级到新版本,来验证版本在生产环境下的表现。一般是看有无大面积崩溃和核心业务流程是否存在问题,若有问题则需要修复后再进行灰度验证。
A/B验证 通过对比老版本和新版本的流程数据,来验证各业务的转化是否满足预期。若不满足预期,则需要分析具体原因,调整策略或修改代码重新A/B验证。
全量发布 将APP提交到应用商店,供用户下载更新。经过上面的两个验证阶段,一般来说核心流程是没问题的,可以把精力投入到下一阶段的需求研发中。
在灰度验证阶段完成后,根据借款APP发布的习惯,Android 和 iOS 端需要再构建的次数和原因分别如下:
Android 端共4次,原因都是为了修改版本号
阶段 | 次数 | 原因 |
---|---|---|
A/B 验证 | 2 | A包和B包。在A/B验证阶段一般会通过区分APP版本号来做数据隔离,即:A包为基于上个全量版本的代码构建(新的版本号),B包为基于当次灰度验证阶段通过的代码构建(新的版本号)。 |
全量发布 | 2 | 商店包与Landing 投放包。Landing 包版本号要比商店包版本号大的原因为:在用户通过浏览器下载安装包时,防止用户下载Landing包时被系统应用商店拦截,从而替换为商店包,影响投放分析;若Landing 包的版本号比商店包大,则不会发生此问题。 |
iOS 端1次构建,将投放渠道由 TestFlight 改为 AppStore。下面是使用 TestFlight 的原因和限制:
如你所见,在灰度验证阶段完成后,剩余阶段的构建其实并没有修改代码,只做了配置的修改(版本号或渠道都属于配置)。前端的「一包到底」已经纳入了公司前端发布标准流程中,APP 端作为大前端的重要组成部分自然不能掉链子,下面我们一起来实现 APP 的「一包到底」方案。
注:为行文方便,下文统一将灰度阶段验证通过或已全量的安装包 简称为基准包
核心目标 不重新构建代码
预期收益 提升发版效率
是否可以拿到运行时的环境变量或安装的路径来辅助判断 APP 是否为 TestFlight 安装的呢?
通过寻求网友的帮助,我们找到了通过判断[NSBundle appStoreReceiptURL]
的值是否包含字符串sandboxReceipt
的方法来判断APP 是否为 TestFlight 安装的方法。以下是网友的提问与被采纳的回答(部分):
For an application installed through TestFlight Beta the receipt file is named StoreKit\sandboxReceipt vs the usual StoreKit\receipt. Using [NSBundle appStoreReceiptURL] you can look for sandboxReceipt at the end of the URL.
NSURL *receiptURL = [[NSBundle mainBundle] appStoreReceiptURL];
NSString *receiptURLString = [receiptURL path];
BOOL isRunningTestFlightBeta = ([receiptURLString rangeOfString:@"san> dboxReceipt"].location != NSNotFound);
通过线上埋点验证,上面的方法确实能判断APP 是否为 TestFlight 版本。
通过上面的验证结果,可以得出最终 iOS 端APP 的「一包到底」方案为:
[NSBundle appStoreReceiptURL]
来判断;APK 是 Android 安装包(Android Package),本质上是个ZIP压缩包
Android 证书和签名
Android 应用版本信息 对 Android 系统来说应用版本信息包括versionName
和versionCode
versionName
字符串,通常所说的版本号,如1.1.0
。对系统来说,它的值除了给用户看之外,没有其他用途versionCode
正整数,内部版本号。此数字仅用于确定某个版本是否比另一个版本更新:数字越大表示版本越新。Android 系统使用versionCode
值来防止应用降级这两个值在APK 包内的AndroidManifest.xml
中定义:
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.myapp"
android:versionCode="1"
android:versionName="1.0" >
...
</manifest>
注:下文若提到 某版本号比某版本号大,则默认表示versionName
和versionCode
都大
包括AndroidManifest.xml
在内的资源文件在构建后都会被编译成二进制格式。
2.0.5
、B2.0.6
。为了让A包能拉取正确的版本配置,必须要知道A包的真实版本(如1.0.10
),所以借款APP 在AndroidManifest.xml
中自定义了一个内部版本号,用来标识APP 的真实版本信息,示例如下代码所示。除了A包外,其余包的内部版本号与versionName
保持一致。<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.myapp"
android:versionCode="4"
android:versionName="2.0.5" >
...
<application ...>
<!-- 内部版本号 -->
<meta-data
android:name="versionNumber"
android:value="1.0.10" />
</applicaton>
</manifest>
如果能修改基准包中AndroidManifest.xml
里定义的版本号,就可以实现不构建代码同时又能满足版本号修改的目标,步骤如下:
AndroidManifest.xml
文件,并按文件格式解析;AndroidManifest.xml
中的versionName
、versionCode
、versionNumber
值;AndroidManifest.xml
重新编译成二进制格式,并替换掉新包中的AndroidManifest.xml
文件;核心思路还是比较简单明了的,但其中的难点在于如何正确的解析二进制AndroidManifest.xml
文件。这就必须要了解二进制AndroidManfiest.xml
的文件格式,然后再对其进行修改。
文件内容分为四大部分,这里只做简单介绍,详细的介绍可以看Apk解析之 —— AndroidManifest.xml
Header
由 Magic Number
和File Size
组成,其中Magic Number
始终等于0x0008003
String Pool Chunk
字符串池。所有字符串资源都声明在这个池里面,有字符串使用的地方,都是记录字符串在池中的索引,通过索引来访问对应的字符串。修改版本号主要是对String Pool Chunk
中的字符串进行编辑和新增ResourceId Chunk
系统资源 id 信息XmlContent Chunk
清单文件中的具体信息,其中包含了五个部分:Start Namespace Chunk
、End Namespace Chunk
、Start Tag Chunk
、End Tag Chunk
、 Text Chunk
二进制 AndroidManifest.xml 文件各部分的关系
Android 官方提供的Command line tools
中的apkanalyzer
命令,可以反编译出 APK 包中的AndroidManifest.xml
文件内容
apkanalyzer manifest print myapk.apk
输出内容如下所示:
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
android:versionCode="1"
android:versionName="0.0.1"
android:compileSdkVersion="29"
android:compileSdkVersionCodename="10"
package="com.example.myapp"
platformBuildVersionCode="29"
platformBuildVersionName="10">
<uses-sdk
android:minSdkVersion="16"
android:targetSdkVersion="28" />
<application
android:theme="@ref/0x7f0d00f4"
android:label="@ref/0x7f0c001b"
android:icon="@ref/0x7f070058"
android:debuggable="true"
android:testOnly="true"
android:allowBackup="true"
android:supportsRtl="true"
android:appComponentFactory="androidx.core.app.CoreComponentFactory" >
<meta-data android:name="versionNumber" android:value="1.0.10" />
</application>
</manifest>
那么这个“不起眼”的apkanalyzer
命令是如何做到解析AndroidManifest.xml
文件的呢?通过分析命令文件的内容发现其调用了apkanalyzer.jar
,通过工具查看jar包的内容发现了BinaryXmlParser.class
类,在其中看到了我们在上文提到的String Pool Chunk
和其他Chunk
的解析逻辑,如下图所示:
Chunk
类是在binary-resources.jar
中定义的,完整包名是com.google.devrel.gmscore.tools.apk.arsc.Chunk
。Chunk
实现了SerializableResource
接口,可以大胆猜测,所有Chunk
的子类都是可以在修改后重新被序列化成二进制格式的,最终也证实了确实可以。正是这个发现使得Android 「一包到底」功能得以最终实现。
基于binary-resources.jar
进行修改操作的步骤如下:
将AndroidManifest.xml
内容解析成BinaryResourceFile
对象
import com.google.devrel.gmscore.tools.apk.arsc.*;
// 省略基于基准包复制一个新包的代码
...
// 通过Zip读取新包中的 AndroidManifest.xml 内容
ZipFile apkFile = new ZipFile(newApk);
ZipEntry entry = apkFile.getEntry("AndroidManifest.xml");
InputStream manifestStream = apkFile.getInputStream(entry)
// 获取 BinaryResourceFile 对象
BinaryResourceFile manifest = BinaryResourceFile.fromInputStream(manifestStream)
从BinaryResourceFile
对象中获取所有Chunks
,找出StringPoolChunk
和XmlStartElementChunk
(即上文提到的Start Tag Chunk
) 并进行修改
获取所有Chunks
List<Chunk> chunks = ReflectionHelpers.getField(manifest, "chunks");
XmlChunk xmlChunk = (XmlChunk) chunks.get(0);
Collection<Chunk> chunksList = xmlChunk.getChunks().values();
遍历XmlStartElementChunk
,找出versionName
、versionCode
和versionNumber
属性
XmlAttribute versionCodeAttr = null;
XmlAttribute versionNameAttr = null;
XmlAttribute versionNumberAttr = null;
for (Chunk it : chunksList) {
if (it instanceof XmlStartElementChunk) {
XmlStartElementChunk elementChunk = (XmlStartElementChunk) it;
// <manifest android:versionCode="1" android:versionName="0.0.1" ></manifest>
if ("manifest".equals(elementChunk.getName())) {
for (XmlAttribute attr : elementChunk.getAttributes()) {
if ("versionName".equals(attr.name())) {
versionNameAttr = attr;
} else if ("versionCode".equals(attr.name())) {
versionCodeAttr = attr;
}
}
} else if ("meta-data".equals(elementChunk.getName())) {
// <meta-data android:name="versionNumber" android:value="1.0.10" />
List<XmlAttribute> attrs = elementChunk.getAttributes();
for (XmlAttribute attr : attrs) {
if (!"name".equals(attr.name()) || !"versionNumber".equals(attr.rawValue())) {
break;
}
for (XmlAttribute a : attrs) {
if ("value".equals(a.name())) {
versionNumberAttr = a;
}
}
break;
}
}
}
}
从将Chunks
中找出StringPoolChunk
,进而获得StringPool
List<String> stringPool = null;
Collection<Chunk> chunksList = xmlChunk.getChunks().values();
for (Chunk it : chunksList) {
if (it instanceof StringPoolChunk) {
stringPool = ReflectionHelpers.getField(it, "strings");
break;
}
}
if (stringPool == null) {
throw new NullPointerException("string pool should not be null.");
}
修改versionName
、versionCode
的值,新增versionNumber
的值
assert versionCodeAttr != null;
BinaryResourceValue versionCodeValue = versionCodeAttr.typedValue();
int versionCodeBefore = versionCodeValue.data();
ReflectionHelpers.setField(versionCodeValue, "data", versionCode);
System.out.printf("versionCode [%d] => [%d]%n", versionCodeBefore, versionCodeValue.data());
assert versionNameAttr != null;
String versionNameBefore = versionNameAttr.rawValue();
String versionNumberBefore = versionNumberAttr == null ? null : versionNumberAttr.rawValue();
// update versionName's value. replace the origin version name in string pool.
stringPool.set(versionNameAttr.rawValueIndex(), versionName);
System.out.printf("versionName [%s] => [%s]%n", versionNameBefore, versionNameAttr.rawValue());
// update versionNumber's value.
if (versionNumberAttr != null) {
int index = stringPool.indexOf(fixedVersionNumber);
// 先在 SrtingPool 中查找是否存在字符串,存在则直接使用索引,不存在则插入,并记录索引
if (index == -1) {
index = stringPool.size();
stringPool.add(fixedVersionNumber);
}
ReflectionHelpers.setField(versionNumberAttr, "rawValueIndex", index);
ReflectionHelpers.setField(versionNumberAttr.typedValue(), "data", index);
System.out.printf("versionNumber [%s] => [%s]%n", versionNumberBefore, versionNumberAttr.rawValue());
}
最后将BinaryResourceFile
对象重新序列化成二进制格式,并写入新的AndroidManifest.xml
中
byte[] newManifestContent = manifest.toByteArray();
// 省略将 newManifestContent 写入新文件的代码
...
这一步我们将使用 AAPT 工具来把修改后的AndroidManifest.xml
写入到新包中。因 AAPT 无法替换包内文件,所以需要先从包中删除,然后再添加:
AndroidManifest.xml
文件aapt r new.apk "AndroidManifest.xml"
aapt r -k new.apk "out/AndroidManifest.xml"
Android 项目在构建时,会自动生成BuildConfig
文件,并在其中定义版本号信息,示例代码如下:
package com.example.myapp;
public final class BuildConfig {
public static final boolean DEBUG = Boolean.parseBoolean("false");
public static final String APPLICATION_ID = "com.example.myapp";
public static final String BUILD_TYPE = "release";
public static final String FLAVOR = "";
public static final int VERSION_CODE = 2;
public static final String VERSION_NAME = "2.0.0";
}
通常在写代码时,有用到版本号的地方都会直接使用BuildConfig
中定义的版本信息,因为它的值和AndroidManifest.xml
中的版本信息是一致的。
String versionName = BuildConfig.VERSION_NAME
但在「一包到底」方案实施的过程中我们只修改了AndroidManifest.xml
的版本号信息,并未对BuildConfig
中的版本号进行修改,里面的值还是基准包的版本号信息。所以我们需要找出代码中使用版本号的地方,替换成从AndroidManifest.xml
中获取版本号的方式来读取正确的版本号信息。
// String versionName = BuildConfig.VERSION_NAME
String versionName = getVersionName(context);
public static String getVersionName(Context context) {
PackageManager packageManager = context.getPackageManager();
PackageInfo packageInfo = packageManager.getPackageInfo(BuildConfig.APPLICATION_ID, 0);
return packageInfo.versionName;
}
这是 Android「一包到底」方案在代码层面上唯一要注意的地方。
最终我们通过修改基准包的版本号,达到了既能不构建代码又能满足版本号修改的目标。实测下来运行耗时在秒级,对于新包我们只需要关注版本号是否修改正确即可,大大提升了发布效率。
本文介绍了借款APP「一包到底」方案的应用实践,从 iOS 和 Android 两端的实际情况出发,通过分析与探索最终分别落地了「一包到底」方案
[NSBundle appStoreReceiptURL]
的方式得知是否为 TestFlight 渠道AndroidManifest.xml
中的版本号信息,生成新包后续的规划是把 Android 「一包到底」方案部署到投放渠道管理平台中,只需要在平台上操作即可直接基于基准包生成新包并对外投放,减少人工参与。
最后,如果本文能给大家带来一点收获就再好不过了!欢迎大家提出一些建议,非常感谢
Wenz,信也科技移动端技术专家
Java、大数据、前端、测试等各种技术岗位热招中,欢迎扫码了解~
更多福利请关注官方订阅号“拍码场”
好内容不要独享!快告诉小伙伴们吧!