游戏发行技术平台设计方案(三):打包与分包
系列第三篇。上一篇讲接入层(SDK 接入层),这一篇讲发行平台最”重”的一块:包。
依然四段:业务背景 → 方案设计 → 关键取舍 → 真实经验。
一、业务背景
为什么发行平台要自己管分包
买量投放有个基本前提:投出去的钱要能追回来。
广告平台给的是一个计划 ID,用户从某个计划点进来、下载、注册、付费——这条链路必须能对上。对不上的直接后果是:不知道该加哪个计划的预算,也不知道哪个计划在亏钱。
所以投放需要的不是”一个安装包”,而是一个计划一个包——包里带着这个计划的身份,客户端装上之后能报出来”我是从哪个计划来的”。
渠道合作是同一个问题的另一种形态:不同渠道要分开统计、分开结算,也需要渠道包。
再加上 iOS 不能走公开商店的场景,需要私域分发(企业签名 + 网页安装)。这三件事加起来,就是”打包与分包”这个模块要解决的:
| 需求 | 包的类型 | 归因对象 |
|---|---|---|
| 买量投放 | 投放分包 | 广告计划 |
| 渠道合作 | 渠道分包 | 渠道 |
| iOS 私域 | 私域分发包 | 计划 / 渠道 |
包不是孤立的交付物
这是做发行平台才会有的体感:包只是投放三件套之一。
flowchart LR
A["推广包<br/>(带身份标识)"] --> D["投放"]
B["点击监测链接<br/>(平台回传点击)"] --> D
C["落地页<br/>(短链分发)"] --> D
D --> E["用户安装"]
E --> F["归因"]
- 推广包:带身份标识的安装包
- 点击监测链接:用户点击广告时,广告平台回调我们,我们把这次点击存下来(点击记录里带着设备标识),后面用户激活时用来匹配
- 落地页:投放的承接页,短链分发,页面上的下载按钮指向这个包
三者都挂在同一个”推广包”对象上(一个 adv_id 串起来),所以数据模型里它们是同一张表加两张关联表,而不是三个独立系统。
核心矛盾
运营一天要开几十个包,手动出包根本跟不上;但把出包搬到服务端,就意味着”构建”这件事进了线上系统——慢、重、还可能失败。
慢、重、会失败,这三件事决定了后面所有的设计。
二、方案设计
2.1 推广包模型
核心表是推广包(adv_packs),关键字段:
| 字段 | 说明 |
|---|---|
gid |
归属的游戏(子游戏粒度,因为不同平台要出不同的包) |
adv_type |
推广方式:自然量 / 信息流 / CPS 分包 / CPS 星图 / 抖音直播 |
adv_plan_name |
广告计划名称(下面单独讲,这是全表最严格的字段) |
adv_pack_url |
包文件地址(分包产物) |
adv_click_monitor |
点击监测链接 |
status |
分包状态机 |
adv_plan_name 为什么是全表最严格的字段
它不是”给运营看的名字”,而是对账键——必须与广告平台上的 Campaign Name 完全一致,因为它要参与”平台数据 vs 我们数据”的逐计划对账。
所以约束是三层叠加的:
- 存储前去除首尾空白
- 值中不允许出现任何空白字符(含中间空格)
- 全表唯一,业务校验 + 数据库唯一索引双重保证
把业务键当普通文本字段存,是这类系统最常见的技术债。 一旦库里出现”计划名 A “和”计划名 A”两条,对账就永远对不平。
自然量包是自动生成的
每个子游戏创建时,系统自动生成一个自然量包(名称格式含游戏标识 + gid + “自然量”),它豁免上面那条 Campaign Name 匹配规则——因为自然量本来就没有投放计划。
它的作用是兜底:用户不是从广告进来的,激活时匹配不到任何计划,就归到自然量包上。没有这个兜底,”未归因”就会变成一个到处都要特殊处理的分支。
2.2 分包流水线
主包(每个子游戏上传一份)→ 分包任务 → 推广包,整条流水线:
flowchart TB
U["运营上传主包<br/>(apk / ipa)"] --> P["平台存储主包<br/>按 年月/游戏标识 归档"]
P --> S["调度器(每分钟)<br/>扫描:有主包、无分包产物、状态为待分包"]
S --> J["分包任务"]
J --> J1["解压主包到临时目录"]
J1 --> J2["注入身份标识<br/>(包内约定目录 / 推广包 ID)"]
J2 --> J3["重新打包<br/>(新文件名,带随机后缀)"]
J3 --> J4["落盘到 CDN 目录"]
J4 --> R["回写推广包<br/>产物地址 + 状态=分包成功"]
几个关键设计:
① 注入的是包内目录,不是配置文件,也不依赖接口下发
分包时在包里生成一个约定目录(渠道 SDK 约定位置),目录名就是推广包 ID。
为什么不做成”启动时调接口拿计划 ID”?因为安装完第一次启动时网络不可靠,而且接口方案意味着”分包”退化成一个配置动作,包本身不带身份——一旦接口挂了或请求失败,这个用户就永久丢失归因。身份必须在包里,装到机器上就是确定的。
② 文件名即身份,随机后缀即缓存失效
分包产物文件名格式:
{游戏标识}_{gid}_{adv_id}_{adv_type}_{创建人}_{4位随机后缀}.apk
中间那段是身份,最后 4 位随机十六进制是缓存击穿用的:
CDN 时代的铁律:改了内容不改文件名,等于没改。 重新分包必须产生一个新的 URL,否则 CDN 节点上还是旧包。
代价是旧包永远保留(不删除)——存储成本换可回溯性,这个账在发行场景是划算的:一个包才多大,但”这个计划当时投的是哪个包”是要能查的。
③ 状态机要能区分”排队”和”失败”
分包状态:无需分包 / 没有主包 / 待分包 / 分包中 / 分包成功 / 分包失败。
“待分包”这一档是后补的,补它的原因值得记一下:最初没有这一档,重新分包的做法是把状态改成”分包失败”,让调度器捡起来。功能上能跑,但语义上是污染的——运营看到”失败”会去排查,技术排查日志时也分不清是真失败还是排队中。
用错误状态当队列占位,是所有状态机设计里最容易犯又最容易被容忍的错误。 它不会报错,只会让所有看状态的人(包括未来的自己)判断失真。
2.3 异步生命周期与运营体验
分包是分钟级的操作(解压 + 打包 + 落盘),前端如果只弹个”成功”toast,运营会以为卡住了。
所以分包状态在前端是一等公民:
- 列表页有状态列,一眼看到每个包在哪个阶段
- 页面级说明:明确告知”iOS 私域分包是异步的,通常需要 1-5 分钟”
- 有活跃任务才轮询(10-15 秒一次),全部到终态自动停止——不给后端制造无意义的空转
- 处理中的行,“重新分包”按钮禁用——防止运营连点造成重复排队
这套做法没有用 WebSocket,也没有做假的百分比进度条。诚实的阶段状态 + 自动刷新,比一个猜出来的进度条有用得多。
2.4 iOS 私域分发
iOS 私域走的是企业签名 + 网页安装,整条链路和 Android 完全不同:
flowchart LR
A["主包出包<br/>(未签名 IPA)"] --> B["合作公司打企业签"]
B --> C["回传已签名 IPA"]
C --> D["走分包流程<br/>(注入推广标识)"]
D --> E["上传 CDN"]
E --> F["短链 / 落地页"]
F --> G["itms-services 协议"]
G --> H["清单文件(plist)"]
H --> I["IPA 下载 + 系统安装"]
① 企业签名是外部合作能力,不是自建
企业签名证书的获取和使用有门槛(申请资质、证书管理、被吊销的风险),实际做法是向外部合作公司购买签名服务:
- 自己的主包出好后,交给合作公司打企业签
- 拿回已签名的 IPA
- 再走自己的分包流程
注意这里的顺序:签名在前,分包在后。
这不是随便排的。分包注入的是包内的数据目录(一个带推广标识的目录),不触碰二进制和签名文件——所以对已签名的包仍然有效。如果反过来(先分包再送签),那每一次分包都要重新走一遍外部签名流程,出包节奏就被外部依赖卡住了。
设计推论:凡是依赖外部流程的环节,都要尽量安排在”变化最少”的那一侧。 签名是低频动作(只在主包更新时做一次),分包是高频动作(每开一个投放计划就要出一个包)——把高频的放在自己这边,外部依赖就从”每次都要等”变成”一次就好”。
② 安装链路的技术要点
itms-services://?action=download-manifest&url={清单地址} ——iOS 通过这个协议从网页触发安装,清单文件里写明 IPA 的下载地址。
③ 这里有一个去掉异步阶段的设计
最初清单文件是”生成出来的”——分包完成后,再跑一个任务生成 plist 文件存起来。但仔细看它的内容:清单里所有信息都是确定的(游戏名、包标识、IPA 地址),完全可以按 adv_id 实时渲染出来。
于是改成动态路由:/p/plist/{adv_id},按需返回清单内容。
收益连锁反应:清单生成任务删掉、清单状态字段删掉、清单路径字段删掉,用户可见的异步阶段从三个减到两个(等待 / 分包中 → 就绪或失败)。重新分包后,清单自动指向新包,不需要任何额外动作。
“这个中间产物是否必要”这个问题,值得在每个异步环节问一次。 很多异步阶段不是因为真的需要时间,而是因为第一步就把它设计成了”文件”。
2.5 落地页
落地页是投放的承接页,设计上两个决定:
① 模板 + 实例分离
- 模板(
landing_page_templates):HTML 内容,用占位符标记变量,必须包含游戏名和下载链接两个占位符 - 实例(
landing_pages):关联某个推广包,生成短链
② 动态渲染,不做静态快照
早期做法是”生成一个静态 HTML 文件存到 CDN”。后来改成实时拉最新的模板 + 最新的推广包 + 最新的游戏数据渲染。
为什么?因为静态快照的维护成本极高:改了模板要重新生成所有页面;推广包重新分包后,旧页面上的下载链接还是老包。动态渲染让”改模板”和”重新分包”这两个动作对落地页零成本。
短链(/p/{短码})指向渲染服务,天然跟着数据走。
2.6 渠道分包(仅国内 Android)
渠道分包只在国内 Android 有,因为需求来自”上架应用商店”:
游戏要上架华为、小米这类应用商店,每家商店都要求接入它自己的 SDK,并且各自有一套对接参数(商店标识、结算与统计参数等)。这些参数必须随包提交,不能靠服务端下发。
做法是自研的聚合 SDK——也就是行业里常见的那类”发行聚合 SDK”(一套 SDK 对接多渠道的登录、支付、上报):
flowchart LR
M["主包(未带渠道)"] --> AG["自研聚合 SDK"]
CH["渠道配置<br/>(华为 / 小米 / …)"] --> AG
AG --> P1["华为渠道包"]
AG --> P2["小米渠道包"]
AG --> P3["其他渠道包"]
聚合 SDK 在这里承担两件事:
- 屏蔽各渠道 SDK 的差异:一套接入代码对接多家商店的登录、支付、上报
- 按渠道把对应参数写入包体,产出对应的渠道包
注意它和投放分包的关系:两者是”同一条流水线上的两种包”,但注入的东西不同——
| 注入内容 | 归因对象 | 平台 | |
|---|---|---|---|
| 投放分包 | 广告计划标识 | 计划 | Android + iOS 私域 |
| 渠道分包 | 渠道商店对接参数 | 渠道 | 仅国内 Android |
iOS 没有渠道分包:要么走 App Store(参数由平台统一处理),要么走私域企业签(不经过商店),都不存在”给某个商店出包”这件事。
设计上的取舍是:聚合层自研,而不是直接用第三方聚合 SDK。
原因和第八章”三方 → 自研”是同一条主线——聚合层手里握着”渠道参数怎么进包”和”每个渠道的数据长什么样”,这两件事直接决定渠道结算和数据口径。把这层交出去,等于把渠道数据的解释权交出去。 而且自研聚合层可以和自己的分包流水线、归因体系直接打通,不需要在中间做一层格式转换。
三、关键取舍
| 决策 | 选择 | 放弃的方案 | 理由 |
|---|---|---|---|
| 出包位置 | 服务端自动分包 | 运营本地手工出包 | 手工出包无法保证身份标识正确,且不可追溯、不可重放 |
| 身份注入方式 | 包内目录 | 启动时接口下发 | 首次启动网络不可靠,接口挂了就永久丢归因 |
| 缓存处理 | 文件名加随机后缀 | 刷新 CDN 缓存 | 加后缀对 CDN 无感,且旧包天然保留可回溯 |
| 旧包清理 | 永久保留 | 定期删除 | 存储成本低,”当时投的是哪个包”必须可查 |
| 清单文件 | 动态路由渲染 | 生成 plist 文件存储 | 内容完全确定,异步阶段是自找的 |
| 落地页 | 动态渲染 | 生成静态 HTML | 静态快照改模板要全量重生成,分包后链接还会失效 |
| 状态机 | 显式”待分包”状态 | 复用”失败”状态当队列 | 语义污染会让运营和排查同时失真 |
| 渠道适配 | 自研聚合 SDK(发行聚合 SDK 那一类) | 直接用第三方聚合 SDK | 渠道参数进包方式与渠道数据口径要握在自己手里,且可与自有分包/归因链路直接打通 |
| 企业签名 | 外部合作公司签名 | 自建签名能力 | 申请资质与证书风险不划算;签名放在分包之前,把高频动作留在自己这边 |
| 计划名约束 | 全表唯一 + 禁空白 | 当普通文本存 | 它是对账键,脏数据直接导致对账对不平 |
四、真实经验
这一节是这一章最值钱的部分——从设计前提(签名和分包谁先谁后)到实现细节(和操作系统打交道),都是实际踩过或者差点踩到的。
1. 用 zip 打 IPA,参数选错会直接毁掉包结构
打包 iOS 的 IPA,最初尝试了几种 zip 参数组合,全部失败:
- 加了
-j(丢掉目录结构)→ 报 “cannot repeat names in zip file” - 从目录外直接指定路径压缩 → 报 “Nothing to do!” 或 “I/O error: Invalid argument”
正确写法是三条一起:
cd /tmp/tempdir && zip -q -r -0 /tmp/output.ipa .
- 不要
-j:IPA 的Payload/目录结构是格式的一部分,剥掉就不是 IPA 了 - 先
cd进目录,源写.:从目录内部压缩,而不是从外部指定路径 -0不压缩:IPA 内部已经是压缩过的资源,再压缩没收益,还会引入扩展属性问题
这类问题的特点是:报错信息完全看不出原因,只能靠理解格式本身。 知道”IPA 是带目录结构的 zip”这件事,比记住参数重要。
2. 跨文件系统的 rename 会失败,而且 PHP 和 shell 的行为不一样
分包流程里要把产物从临时目录搬到 CDN 目录,代码用 rename() 直接报:
rename(/tmp/xxx.ipa, /data/uploads/...): Operation not permitted
排查发现这不是权限问题,是内核层面的限制:
/tmp在本地文件系统上- CDN 目录是对象存储的 FUSE 挂载
- POSIX 规定
rename()不允许跨文件系统(返回EXDEV)
更有意思的是为什么 shell 的 mv 是好的:
shell 的
mv检测到EXDEV会自动降级成”复制 + 删除”;而 PHP 的rename()直接调系统调用,没有任何降级。
所以解决方案是显式地做这件事:
exec("cp '{$src}' '{$dst}' && rm -f '{$src}'", $output, $ret);
同一个操作,命令行和语言的语义不同——这类差异在”脚本里能跑、代码里不行”的场景下,往往就是这个原因。
3. 系统用户的”没有 shell”会以很奇怪的方式暴露
分包调度挂在系统 cron 上。第一次部署时 cron 报 flock: command not found——但服务器上明明装了 flock。
原因:任务要以 web 用户身份运行,而这个用户被配置成没有登录 shell,cron 直接执行命令时找不到 PATH。
解法是用 shell 包一层:
sudo -u {web用户} /bin/sh -c '...'
“命令不存在”报错的第一反应不该是”没装”,而是”这个上下文里 PATH 是什么”。 换一个执行上下文(cron、systemd、sudo)就换了一套环境,这是部署类问题的通用规律。
4. 大文件上传的限制有三层,少配一层就是 413
上传主包(IPA/APK 动辄几百 MB)时遇到 413,最后发现要三层一起放开:
- nginx 的
client_max_body_size - PHP 的
post_max_size和upload_max_filesize - 应用层对上传大小的校验
任何”传大文件”的需求,都要先把这三层数一遍。 只改一层,报错还会以别的形式回来。
5. “失败”不能当”排队”用
前面提过一次,这里再强调:重新分包最初是靠把状态改成”分包失败”来触发调度器重扫的。
它带来的实际成本是:运营看到失败会来问、技术排查时要先去翻日志确认是哪种”失败”。一个状态的语义被复用,所有读它的人就都多了一次判断成本。 补一个”待分包”状态的成本,远低于长期让所有人多问一句”这个失败是真的还是排队”。
6. 改了内容不改名字,等于没改
这条是分包带出来的通用规律,值得单独记:
在 CDN 面前,URL 就是缓存键。 重新生成产物必须换 URL,否则用户拿到的是旧内容,而且这个问题在你的机器上永远复现不出来——只有 CDN 边缘节点上才有旧副本。
所以产物文件名里带了随机后缀,每一次生成都是新 URL。副作用是文件会累积,但配合”按年月归档”的目录结构,管理和回溯都不成问题。
7. 注入位置决定了自动化能不能成立
这条是”设计前提”级别的经验,值得放在最后说。
分包能自动化的前提是”注入动作不破坏签名”——所以注入点选在了包内的数据目录,不碰二进制、不碰签名文件。
反过来说:如果当初把推广标识设计成”写进二进制”或”改签名文件”,那么每一次分包都要重新送外部签名,出包周期从分钟级变成天级,整个自动化流水线就不成立了。
这类”位置选择”的决策,看起来是技术细节,实际决定了系统的能力边界。 做类似设计时值得先问一句:这个动作,会不会让某个昂贵的流程被迫重做一遍?
下一篇
(四)数据体系:事件模型、宽表设计、分层报表、归因回传——包和点击数据进来之后,怎么变成能指导投放决策的数据 → 继续阅读
本文基于实际项目整理,已脱敏。文中不含任何公司业务数据、配置或凭据。