Android CPS 出包流程

这里主要针对的是引入 AAR 之后需要区分启动之后的渠道包, 涉及到对引入 AAR 配置的重新打包, 也就是 CPS 包.

CPS(Cost Per Sale): 按销售付费追踪包, 用于订单归因、数据上报、返佣计算、链接生成加载统计有效买量成本

比如你作为 发行商, 第三方 研发商 在平台上架游戏, 我们就可能会帮助生成指定渠道包给线下推广或者直播主来付费推广.

这时候的付费推广安装包必须在启动的时候就获得属于自己的 标识, 从而方便把后续登陆/支付(买量和付费成本)纳入分成统计.

这样处理可以方便统计以下数据:

  • 指定渠道的获取用户绩效: 某些渠道拉取新用户多, 方便其渠道下面用户维护其注册用户

  • 指定渠道的付费转化率: 某些渠道付费转化率优秀, 这部分可以转化成推广佣金分成

需要区分 CPS渠道包 的差别, 这两者虽然都是有特殊标识, 但是作用是完全不一样的:

  • 渠道包: 指定平台(比如抖音/快手/谷歌)会单独出个包额外做标识, 独有的渠道标识区分平台来源

  • CPS: 针对自己推广下的来源标识, 用于自身做统一的推广渠道佣金分成

这里有几种 ARR 注入配置内嵌方法:

  • 静态打包: 在 app/build.gradle 的时候写死渠道

  • 外部配置: 在 src/main/assets 生成对应 config.properties 读取加载

  • 服务端拉取: 配置特定带 ?channel=1000 或者 ?platform?=GOOGLE 链接拉取配置

静态配置加载

静态配置一般用于出渠道包(Google)的时候, 在应用的 app/build.gradle 声明以下定义配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
android {
// 其他略

// 声明内部的加载的渠道变量
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'


// 定义写入静态配置: {类型}, {标识KEY}, {标识值}
// 推荐定义为字符串, 单纯数值类型有时候没办法做扩展需求
//
// 必须是 Java 基础类型(String/int/boolean 等), 注意大小写(如 String 而非 string)
// 字符串类型必须用转义双引号包裹(\"值\"), 基本类型(int/boolean)直接写值(无需引号)
// 比如 google 突然需要追加成特殊分叉渠道 google_10001 之类的
buildConfigField "String", "PLATFORM_ID", "\"GOOGLE\""
}

debug {
// 默认打包是 debug 包, 所以也要一起配置
buildConfigField "String", "PLATFORM_ID", "\"GOOGLE\""
}
}
}

同步 Gradle 配置之后在 Application/ActivityonCreate 就可以加载对应属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 继承实现我们自定义上级 Activity
*/
public class MainActivity extends PinoActivity {

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);

// 获取静态打包平台配置
String platformId = BuildConfig.PLATFORM_ID;
Log.d("PinoGame", String.format("platform id = %s", platformId));
}
}

这里就会在 logcat 打印出指定的渠道常量:

1
2025-12-11 02:20:02.703 10369-10369 PinoGame                io.meteorcat.pino.app                D  channel id = GOOGLE

这种一般用于外部平台打包注入, 也就是由 研发端(CP) 必须要传入的配置, 不需要我们作为发行方在 AAR 包处理.

外部配置文件

需要 applib 端的 src/main/assets 目录定义自己的配置文件, 一般都是 {项目名}-config.properties:

1
2
3
4
5
6
# 如果 src/main/assets 目录不存在则新建
# 我这边命名为 pino-config.properties 配置文件
# 默认会优先加载 app 内部配置, 如果不存在才会去加载默认的 lib 内部配置
# 这里默认留空, 一般其实不推荐采用数据库递增的 id 作为标识, 容易被摸排出所有渠道信息
# 而是应该作为字符串原生附加参数来提交上来
pino_channel_id=

之后就是编写配置加载工具类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
import android.content.Context;
import android.content.res.AssetManager;
import android.util.Log;

import java.io.InputStream;
import java.util.Properties;

public class ConfigManager {

/**
* 默认的日志 TAG
*/
private static final String TAG = "PinoConfigManager";

/**
* 默认加载配置文件
*/
private static final String PINO_CONFIG_FILE = "pino-config.properties";

/**
* 加载的属性值
*/
private Properties properties;

/**
* 全局句柄
*/
private static volatile ConfigManager instance;


/**
* 全局单例对象
*/
public static ConfigManager getInstance() {
if (instance == null) {
synchronized (ConfigManager.class) {
if (instance == null) {
instance = new ConfigManager();
}
}
}
return instance;
}

/**
* 初始化配置:优先加载App端配置,失败则加载AAR内置默认配置
*
* @param context 建议传入Application的Context,避免内存泄漏
*/
public void init(Context context) {
properties = new Properties();
// 第一步:尝试加载App端的配置文件
boolean isAppConfigLoaded = loadConfigFromAssets(context.getAssets(), false);
if (!isAppConfigLoaded) {
Log.w(TAG, "未找到App端CPS配置, 加载AAR内置默认配置");
// 第二步:加载AAR内置的默认配置(需用AAR自身的AssetManager)
loadConfigFromAssets(context.getResources().getAssets(), true);
}
}

/**
* 读取配置文件
*
* @param assetManager AssetManager(区分App/AAR)
* @param isAarDefault 是否读取AAR内置配置
* @return 是否读取成功
*/
private boolean loadConfigFromAssets(AssetManager assetManager, boolean isAarDefault) {
try (InputStream is = assetManager.open(PINO_CONFIG_FILE)) {
properties.load(is);
Log.d(TAG, (isAarDefault ? "AAR默认" : "App自定义") + "CPS配置加载成功:" + properties.toString());
return true;
} catch (Exception e) {
Log.e(TAG, (isAarDefault ? "AAR默认" : "App自定义") + "CPS配置加载失败", e);
return false;
}
}

/**
* 获取CPS渠道ID(兜底返回空字符串)
*/
public String getChannelId() {
return properties.getProperty("pino_channel_id", "");
}
}

重新生成 AAR 的 SDK 包, 之后引入 app 包之中确认是否可用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import android.os.Bundle;
import android.util.Log;
import io.meteorcat.pino.PinoActivity;
import io.meteorcat.pino.utils.ConfigManager;

/**
* 继承实现我们自定义上级 Activity
*/
public class MainActivity extends PinoActivity {


@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);

// 获取静态打包平台配置
String platformId = BuildConfig.PLATFORM_ID;
Log.d("PinoGame", String.format("platform id = %s", platformId));

// 获取动态渠道平台配置
// 首次加载默认空字符串, 后续可以测试在 src/main/assets 创建 pino-config.properties 文件写入配置
ConfigManager configManager = ConfigManager.getInstance();
configManager.init(getApplicationContext()); // 初始化加载
Log.d("PinoGame", String.format("channel id = %s", configManager.getChannelId()));

}
}

后续就是在 src/main/assets 创建 pino-config.properties 文件附加 pino_channel_id=10001 内容, 最后打印内容:

1
2
3
4
5
6
7
# 首次加载 ---------------------------------
2025-12-11 02:59:30.894 12858-12858 PinoGame io.meteorcat.pino.app D platform id = GOOGLE
2025-12-11 02:59:30.894 12858-12858 PinoGame io.meteorcat.pino.app D channel id =

# 写入配置 ---------------------------------
2025-12-11 03:01:21.034 13005-13005 PinoGame io.meteorcat.pino.app D platform id = GOOGLE
2025-12-11 03:01:21.034 13005-13005 PinoGame io.meteorcat.pino.app D channel id = 10001

这里就是动态加载配置包信息, 但是这里涉及到另外的问题: 后台渠道ID是动态生成, 不可能让研发方一个个渠道出包, 怎么才能支持动态出包?

动态 properties 注入打包

这里就需要在后台通过脚本手动做安卓重新打包, 也就是重新对 apk 解包之后将值钱的 pino-config.properties 文件写入进去.

需要以下工具来做重新打包并重新对 apk 签名, 默认研发方生成的首个安装包为 母包, 后续重新签名的生成都为 子包:

  • apktool: 用于解包|打包 APK(处理资源文件)

  • apksigner: 用于对重打包后的 APK 签名

  • zipalign: 用于优化 APK( Android 要求对齐)

可以将 apk 看作文件签名过的压缩包, 这里需要做的就是解压这个压缩包添加我们自己的文件重新压缩, 对这个新的压缩包重新签名.

注意: 母包和子包的签名相关信息都要一致, 否则会打包过程之中母包无法生成子包

这里采用 Linux 平台来处理, 一般动态分出 CPS 渠道都是由后台服务端创建并生成特定ID, 之后服务端调用脚本生成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# 需要保证上面的安装工具已经安装配置完成
# 假设默认创建的打包目录为 /tmp/android
mkdir /tmp/android

# 1. apk解包
# 这里假设游戏研发商提交了 apk 母包, 并且 CPS 渠道ID 为 10001
# 将母包资源解包到目录之中, 约等于压缩包解压
apktool d -f pino-relase.apk -o /tmp/android/10001

# 2. 写入内部 CPS 渠道配置文件
# 覆盖或者创建 assets 下的 pino-config.properties 文件
echo "pino_channel_id=10001" > /tmp/android/10001/assets/pino-config.properties

# 3. 重新生成打包(这里的子包目前还未签名)
apktool b -f /tmp/android/10001 -o /tmp/android/10001/unsigned.apk

# 4. 对齐 APK 包优化
zipalign -f 4 /tmp/android/10001/unsigned.apk /tmp/android/10001/aligned.apk

# 5. 最后开始针对对齐的 apk 包签名
# 注意这里需要指定的签名文件和信息:
# {自定义的签名文件名}.jks, 一般可以在发行后台创建游戏应用的时候生成的 jks 签名文件
# 签名的别名, 这部分也是可以在平台平台创建应用的时候生成
# 签名的密码, 同上, 也是创建那一刻直接生成
# 密钥库密码, 同上, 也是创建那一刻直接生成
#
# 这里这些信息我放置于 /keys 目录, 并且以 /keys/{后台应用ID} 来分别放置, 假设应用ID为 1002, 那么就会生成如下文件:
# - /keys/1002/key.jks: 签名文件
# - /keys/1002/key.alias: 签名别名文件, 执行命令行脚本可以直接读取放置变量, 这里假设为 "pino_10001" ( {项目名}_{渠道ID} )
# - /keys/1002/key.store: 密钥库密码文件, 同上读取数据(也可以读取数据库加载), 随机生成 MD5 假设为 "xm4kp887d0hi809h225fk4ezwe50uc57"
# - /keys/1002/key.pwd: 签名密码文件, 同上读取, 随机生成 MD5 假设为 "38de6w6sn20but6i22d017i5z6e4m8cz"
#
# 注意: JDK1.8 之后的版本 storepass 和 keypass 不再区分, 所以可能只需要配置单个 storepass 项
jarsigner -verbose -sigalg SHA1withRSA -digestalg SHA1 -keystore /keys/1002/key.jks \
-storepass xm4kp887d0hi809h225fk4ezwe50uc57 -keypass 38de6w6sn20but6i22d017i5z6e4m8cz \
/tmp/android/10001/aligned.apk pino_10001 \
-signedjar /tmp/android/10001/signed.apk
# 最后的 /tmp/android/10001/signed.apk 就是签名完成的文件, 可以将其复制到对应目录之下然后清理掉现有打包过程产生的目录

这样生成的 signed.apk 就是最终单独指向 channel=10001 来源的 apk, 后续就是对应渠道的推广员获取自己下载 apk 的链接去给用户下载.

签名文件和密码建议通过环境变量或加密存储/数据库/Redis之类读取, 而非硬编码在脚本中(容易服务器泄漏), 打包服务器最好单独部署不要涉及其他服务

不止 channel_id, 内部如果需要可以添加更多动态设置, 比如 SERVER_URL(放置域名失效和变动) / APPID(写死默认的应用ID) 等.

甚至有时候需要针对对应包来做 icon 和 应用名称 的变动修改, 这部分需要比较扎实的服务端处理经验

只是这里涉及到有的云端业务打包, 后面可能需要用 Git + Jenkins 做提交源码并启动远程动态打包处理.

另外需要提供自定义 ApplicationActivity 提供实现, 用于直接在 AAR 底层就处理自己内部初始化.

有时候需要特殊渠道买量包, 如 抖音导流 这种花费资金买流量的包, 比起 BuildConfig 编译, 动态注入 properties 扩展参数标识更灵活.

本地生成签名

上面说过签名需要用到的 *.jks 等相关内容和设置都可以直接通过命令行生成, 具体方式可以采用 Linux 命令处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 依赖 JDK 内置工具 keytool, 确保服务器已经安装 JDK

# 1. 创建签名文件存储目录(按应用ID分类,示例应用ID=1002)
mkdir -p /keys/1002

# 2. 生成JKS签名文件, 每一行配置说明如下:
# -keystore /keys/1002/key.jks # 签名文件存储路径
# -alias pino_1002 # 签名别名(建议:项目名_应用ID)
# -keyalg RSA # 加密算法(推荐RSA)
# -keysize 2048 # 密钥长度(2048/4096,2048足够)
# -validity 3650 # 有效期(10年,单位:天)
# -storepass xm4kp887d0hi809h225fk4ezwe50uc57 # 密钥库密码(对应key.store)
# -keypass 38de6w6sn20but6i22d017i5z6e4m8cz # 签名密码(对应key.pwd)
# 其他的 -dname 地区按照自己需要处理
#
# 注意: JDK1.8 之后的版本 storepass 和 keypass 不再区分, 只需要配置 storepass 即可
keytool -genkey -v \
-keystore /keys/1002/key.jks \
-alias pino_1002 \
-keyalg RSA \
-keysize 2048 \
-validity 3650 \
-storepass xm4kp887d0hi809h225fk4ezwe50uc57 \
-dname "CN=PinoGame, OU=MeteorCat, O=MeteorCat, L=Shanghai, ST=Shanghai, C=CN"

这里可以通过上面我们的测试项目的 debug 包来手动测试自己签名打包, 需要注意因为安卓项目变动很大, 有些指令可能因为版本不同而不可用.

一般如果作为 应用发行方 都是有自己的 发行后台, 需要按照以下流程处理:

  1. 游戏研发方就需要在发行后台创建应用

  2. 发行后台创建应用的同时生成母包所需的签名相关文件

  3. 游戏研发方获取这些签名文件和相关密码引入项目当中生成打包

  4. 游戏研发方将文件通过后台提交到发行方, 发行后台开始针对渠道生成子包

  5. 读取数据库对应的证书和密码, 调用服务端脚本对母包进行子包重新打包生成

  6. 完成子包的分包流程之后, 保存下载到数据提供给渠道用户下载指定的 apk

也有通过游戏研发方自己来提交 jks 和密码参数, 然后分包的时候引用指定参数的做法, 具体区分这些证书相关是要发行负责生成还是研发负责生成

流程目标拆解出来如下表所展示:

阶段 操作方 核心动作 关键产出/数据
1. 应用创建 发行方后台 运营人员创建应用(填写包名、应用名称、渠道规则等),自动生成签名套件 应用ID、JKS文件、别名、storepass、keypass
2. 签名交付 发行方→CP 后台生成签名套件的下载链接(带时效/权限),CP下载后集成到项目 CP本地集成签名的工程配置
3. 母包提报 CP→发行后台 CP用发行方签名打包母包,通过后台上传接口提交母包(校验包名/签名一致性) 母包文件、母包MD5、提报记录
4. 子包生成 发行方后台 读取渠道配置(channel_id)+ 签名信息,调用脚本批量生成子包 各渠道子包APK、子包下载链接、渠道映射表
5. 分发下载 渠道/用户 后台按渠道生成专属下载链接,用户下载对应子包 子包下载日志、渠道数据统计

而签名信息由 研发方 或者 发行方 生成的主要差别如下:

维度 模式:发行方生成签名 模式:研发方提供签名
核心主体 发行方掌控签名全生命周期 研发方自持签名,仅向发行方提交签名信息
适用场景 1. 发行方主导全渠道分发;
2. 研发方无成熟签名体系;
3. 需统一所有渠道包签名
1. 研发方已有应用市场上架签名;
2. 研发方对签名安全要求高;
3. 多发行方合作同一研发方
流程核心差异 发行后台创建应用时自动生成签名→研发方下载集成 研发方在后台上传JKS+填写密码→发行方校验后复用
优势 1. 签名统一,无适配风险;
2. 发行方可控分包流程;
3. 避免研发方泄露签名
1. 兼容研发方现有签名体系;
2. 无需研发方重新适配签名
风险 发行方需承担签名保管责任 1. 研发方提交错误签名导致分包失败;
2. 签名泄露风险由研发方承担

其实主要差别在是否已经将签名提交到应用市场, 如果不想修改原有签名则是要手动提交, 但是本质上这两种方式最好在发行平台都提供.

这章节开头虽然说是 安卓加载外部配置 相关, 但是基本都是围绕 CPS 出包例子说明, 主要是应用最广泛的就是这部分出包逻辑.

注意: 最好初始化的时候就加载到应用ID(APP_ID), 因为面向全球化的应用需要加载默认语言, 这部分需要加载到服务器应用配置的语言项.

初始化的时候, 内部应用其实还有很多事情需要处理, 比如加载默认 i18n 语言模板/获取远程的应用更新版本数据/判断应用是否禁止等