系列第三篇。上一篇讲接入层(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 下载 + 系统安装"]

① 企业签名是外部合作能力,不是自建

企业签名证书的获取和使用有门槛(申请资质、证书管理、被吊销的风险),实际做法是向外部合作公司购买签名服务:

  1. 自己的主包出好后,交给合作公司打企业签
  2. 拿回已签名的 IPA
  3. 再走自己的分包流程

注意这里的顺序:签名在前,分包在后。

这不是随便排的。分包注入的是包内的数据目录(一个带推广标识的目录),不触碰二进制和签名文件——所以对已签名的包仍然有效。如果反过来(先分包再送签),那每一次分包都要重新走一遍外部签名流程,出包节奏就被外部依赖卡住了。

设计推论:凡是依赖外部流程的环节,都要尽量安排在”变化最少”的那一侧。 签名是低频动作(只在主包更新时做一次),分包是高频动作(每开一个投放计划就要出一个包)——把高频的放在自己这边,外部依赖就从”每次都要等”变成”一次就好”。

② 安装链路的技术要点

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 在这里承担两件事:

  1. 屏蔽各渠道 SDK 的差异:一套接入代码对接多家商店的登录、支付、上报
  2. 按渠道把对应参数写入包体,产出对应的渠道包

注意它和投放分包的关系:两者是”同一条流水线上的两种包”,但注入的东西不同——

  注入内容 归因对象 平台
投放分包 广告计划标识 计划 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. 注入位置决定了自动化能不能成立

这条是”设计前提”级别的经验,值得放在最后说。

分包能自动化的前提是”注入动作不破坏签名”——所以注入点选在了包内的数据目录,不碰二进制、不碰签名文件。

反过来说:如果当初把推广标识设计成”写进二进制”或”改签名文件”,那么每一次分包都要重新送外部签名,出包周期从分钟级变成天级,整个自动化流水线就不成立了。

这类”位置选择”的决策,看起来是技术细节,实际决定了系统的能力边界。 做类似设计时值得先问一句:这个动作,会不会让某个昂贵的流程被迫重做一遍?


下一篇

(四)数据体系:事件模型、宽表设计、分层报表、归因回传——包和点击数据进来之后,怎么变成能指导投放决策的数据 → 继续阅读


本文基于实际项目整理,已脱敏。文中不含任何公司业务数据、配置或凭据。