广告系统设计与规划

这篇专题也是总结工作这么多年的对于广告投放业务的积累, 也是方便后来的开发人员做借鉴处理

首选需要明确目前国内提到的 广告 业务实际上有两种概念(很多人都被混淆起来)

  • Marketing(投放买量): 广点通/巨量等广告平台业务, 主要负责投放平台的广告获取点击和流量转化, 本质是买量获客

  • Advertising(广告变现): 硬核渠道等广告展示业务, 利用应用内部的广告位展示获取对应收入, 本质是通过曝光流量变现

维度 投放买量 广告变现
资金流向 作为公司主体(我们)付费给广告平台(广点通/巨量) 不同平台下的广告主付费, 公司主体(我们)拉取点击转化用户来赚取广告分成
服务对象 拉新/促活/付费转化 商业化的流量变现
核心指标 消耗/曝光/点击/CTR/CPC/激活成本/ROI 广告填充率/eCPM/广告展示量/广告收入/LTV
对接对象 广告投放平台API 广告聚合/渠道SDK
参考平台 广点通/巨量/GoogleAds等广告平台 Vivo/Huawei/XiaoMi等应用商店(硬核渠道)

所以在很多情况下, 需要结合情况分清楚提问的人嘴里关于 广告 的概念是哪方面, 才能结合整体思路去处理

日常开发之中, 投放买量和广告变现也不建议直接作为后台入口归类, 主要问题有以下方面

  • 资金流向相反, 结算和对账逻辑完全独立(投放是要买平台量, 变现是需要结算收入)

  • 统计指标不同, 两者核心的统计指标完全不一样, 不建议归类在相同入口处理

另外需要区分游戏联运聚合关系, 很多这类系统开发很喜欢将游戏聚合联运系统和广告平台耦合在一起, 方便把联运系统当中游戏应用作为主体分配

之前接触过部分项目, 把联运游戏内部的游戏应用复用成广告主体对象, 后续要做单独主体抽离出来作为对外开放广告平台时代码逻辑全粘合而无法抽离

无论 广告变现 还是 投放买量, 都遵循以下生命周期(也可以称为 归因链路)

广告展示 → 用户观看 → 点击触发 → 安装下载 → 激活设备 → 账号生成 → 玩家付费(广告变现不需要)

只是两者的最终诉求是不一样:

  • 投放买量需要从点击/安装/激活到付费全程参与归因核算(玩家付费是核心终点指标)

  • 广告变现需要链路仅前半段作为收益结算依据, 末端付费环节不参与广告收益计算(只关注有效点击从而调整广告位下发策略)

这里主要讲解 广告变现 部分功能, 需要说明这部分涉及到 客户端/服务端 部分, 并且还涉及到 应用分包链路追踪

客户端对接

首先服务端将会提供客户端以下接口, 方便做前置对接

  • 广告初始化: 当用户首次启动之后, 客户端搜集客户端信息(主要是IMEI信息), 服务端接受首次启动则代表 激活链路

  • 广告上报事件: 用于广告事件埋点上报, 方便确认广告API初始化是否有异常, 主要提交广告的 状态/错误码/错误信息/IMEI 来分析情况

  • 广告下发配置: 对应广告展示位和概率配置, 部分广告展示需要针对地区和用户做动态调整热门策略(识别IP为部分城市提高广告概率)

广告初始化

一般的初始化相对来说需要提交和下发的东西比较多点, 并且切前后台不需要重复发起请求, 只需要 进程冷启动 的时候请求来做设备信息收集

字段 类型 说明
imei string 设备唯一标识码, 原来 imei 已经淘汰, 现在只是沿用该名称作为唯一标识
source string 第三方来源SDK标识, 比如 vivo/huawei/honer 等通用渠道标识
version string 应用版本号, 用来出现错误分析是否为版本兼容问题
channel string 子渠道标识, 用于细分自身平台的应用分包渠道标识(需要在后台做应用按反编译来注入渠道标识重新打包), 也是归因核心字段
os string 系统: Window/Android/iOS/HarmonyOS/WeChat
device string 手机机型, 一般客户端都有接口获取具体内容, 比如 iPhone17 等设备型号
network string 网络类型: wifi/4g/5g 等
session string 应用会话标识, 需要客户端在进程启动(进程完全杀死后重新打开时候也需要), 生成 UUID 等代表当前启动会话任务唯一性(本地内存生成而不需要持久化保存), 切后台/切前台/弹窗/页面跳转不重新生成而去复用当前会话 id

响应的话不需要带什么数据, 只需要客户端确定提交成即可, 大部分情况只需要 HTTP_STATUS = 200 就代表没问题, 不过这里推荐响应以下结构

lines
1
2
3
4
5
6
7
8
9
10
{
// 是否为首次激活的设备
"is_first_active": 1,
// 响应的 UTC 毫秒时间戳
"time": 1782130486136,
// 客户端IP
"ip": "127.0.0.1",
// 客户端的会话标识
"session": "019eef42-2fa8-77f5-9869-cb524a2539e4"
}

这里其实还有个决策点, 就是 初始化直接是要直接下发广告策略, 还是抽离另外接口请求获取广告策略?, 考虑的点有如下方面

  • 初始化下发广告展示策略: 可以节约请求成本(HTTP请求只有一次), 有效降低接口并发(但响应太多会导致网路不好时候卡顿)

  • 广告策略采用单独接口: 职责抽离(初始化应该做初始化的事情), 可以利用部分手段从服务端动态通知重新拉取广告策略(灵活调整)

广告埋点上报

一般事件的上报结构比较简单, 大部分情况只需要将以下内容上报给服务端

字段名 类型 说明
event number 广告事件枚举, 用于确认埋点行为, 比如初始化/加载/曝光/点击/奖励发放/失败/关闭等事件情况
imei string 设备唯一标识码, 原来 imei 已经淘汰, 现在只是沿用该名称作为唯一标识
source string 第三方来源SDK标识, 比如 vivo/huawei/honer 等通用渠道标识
version string 应用版本号, 用来出现错误分析是否为版本兼容问题
channel string 子渠道标识, 用于细分自身平台的应用分包渠道标识(需要在后台做应用按反编译来注入渠道标识重新打包), 也是归因核心字段
slot_id string 广告的位置ID, 区分广告展示类型, 比如开屏/Bannner/插屏/原生/激励广告等
os string 系统: Window/Android/iOS/HarmonyOS/WeChat
device string 手机机型, 一般客户端都有接口获取具体内容, 比如 iPhone17 等设备型号
state number 细分场景的事件调用状态, 比如正常应该是完整正常展示广告, 还有展示中途被用户切后台中断/页面渲染失败黑屏等意外情况
error number 第三方广告SDK调用错误码, 跟随第三方SDK错误码返回上报
message string 第三方SDK调用异常附加的消息
session string 应用会话标识, 需要客户端在进程启动(进程完全杀死后重新打开时候也需要), 生成 UUID 等代表当前启动会话任务唯一性(本地内存生成而不需要持久化保存), 切后台/切前台/弹窗/页面跳转不重新生成而去复用当前会话 id

依靠这份上报数据就能实现以下归因功能

  • 排查异常: 依靠 source + slot_id + error + message + version 锁定应用版本和异常广告

  • 还原操作:依靠 imei + session + timestamp 的多条信息就可以模拟用户在同一次启动触发流程

  • 精细统计: 依靠 channel + slot_id + event 就能计算曝光/点击/填充率/eCPM等核心指标

  • 行为分析: 依靠 state 统计广告中途退出和播放不完整等流失场景从而做优化

响应的话不需要带什么数据, 只需要客户端确定提交成即可, 大部分情况只需要 HTTP_STATUS = 200 就代表没问题, 所以简单点即可

lines
1
2
3
4
5
6
7
8
{
// 响应的 UTC 毫秒时间戳
"time": 1782130486136,
// 客户端IP
"ip": "127.0.0.1",
// 客户端的会话标识
"session": "019eef42-2fa8-77f5-9869-cb524a2539e4"
}

广告下发配置

用来下发第三方广告策略配置, 提供广告的开关设置/展示概率/厂商渠道优先级/单会话限流/地域倍率等规则, 具体请求参数如下

字段名 类型 说明
imei string 设备唯一标识码, 原来 imei 已经淘汰, 现在只是沿用该名称作为唯一标识
source string 第三方来源SDK标识, 比如 vivo/huawei/honer 等通用渠道标识
version string 应用版本号, 用来出现错误分析是否为版本兼容问题
channel string 子渠道标识, 用于细分自身平台的应用分包渠道标识(需要在后台做应用按反编译来注入渠道标识重新打包), 也是归因核心字段
os string 系统: Window/Android/iOS/HarmonyOS/WeChat
device string 手机机型, 一般客户端都有接口获取具体内容, 比如 iPhone17 等设备型号
network string 网络类型: wifi/4g/5g 等
session string 应用会话标识, 需要客户端在进程启动(进程完全杀死后重新打开时候也需要), 生成 UUID 等代表当前启动会话任务唯一性(本地内存生成而不需要持久化保存), 切后台/切前台/弹窗/页面跳转不重新生成而去复用当前会话 id

这里响应的数据结构就需要着重来说明, 这里推荐采用以下数据格式

lines
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
{
// 响应的 UTC 毫秒时间戳
"time": 1782130486136,
// 客户端IP
"ip": "127.0.0.1",
// 客户端的会话标识
"session": "019eef42-2fa8-77f5-9869-cb524a2539e4"

// 服务端需要通过客户端请求 IP 地址识别出所在地区
// 通过按照不同 IP 地址来做后续的广告展示概率
// 部分热门地区可能广告流程比较长, 所以需要针对地区做策略性展示
"rate": 1.3,
// 针对广告的展示限制
"limit": {
// 针对单次冷启动会话的所有广告展示次数
// 也就是上报的 session 标识允许展示的广告次数
"session_max": 10,
// 广告之间的间隔毫秒数, 大部分推荐的 3~5(3000~5000) 当中取值
// 防止频繁弹窗影响体验
"interval": 3000,
},
// 下发的广告列表
ads: [
{
// 广告位唯一标识
"slot_id": "splash_001",
// 广告位名称
"slot_name": "开屏广告",
// 广告是否启用, 0 代表正常启用, 大于 0 对应对应后台的关闭状态原因枚举, 比如 1 = 正常关闭, 2 = 取消合作, 3 = 违规撤销等
"status": 0,
// 基础展示概率, 以 0.00~1.00 标识 0~100% 展示概率, 这部分收到全局 rate 字段影响
"prob": 0.9,
// 这条广告在当前 session 会话允许播放的最大次数
"session_max": 2
},
{
"slot_id": "reward_001",
"slot_name": "激励视频",
"status": 0,
"prob": 1.0,
"session_max": 8
},
{
"slot_id": "banner_001",
"slot_name": "底部Banner",
"status": 0,
"prob": 0.5,
"session_max": 5
}
// 更多广告.....
]
}

这里广告展示的核心字段就是 rateprob, 按照规则抽取指定广告展示? 其实公式很简单:

1
2
3
4
最终展示概率 = prob * rate

假设: 广告位基础 prob=0.9, 服务端通过IP识别的倍率 rate=1.3
最终得出概率 = 0.9 * 1.3 = 1.17

计算结果会出现两种情况:

  • 计算值 ≤ 1.0: 直接使用该数值作为随机判定阈值

  • 计算值 > 1.0: 统一按 1.0 处理 = 100% 必触发展示

假定 prob=0.9, rate=1.3, value=1.17, 代表该广告只要没有到达 session_max(广告弹出上限), 本次 session 是必定弹出的广告

一般 rate > 1.0 基本是热门地区, 所以广告展示可以相对多触发展示下(要留意过多弹窗导致被拉黑甚至封号)

而对于冷门地区则需要多加次随机浮点数过程来处理

1
2
3
4
5
6
7
假设: 广告位基础 prob=0.9, 服务端通过IP识别的倍率 rate=0.4
最终得出概率 = 0.9 × 0.4 = 0.36

这里需要客户端生成 0.00~1.00 的随机数, 按照这个随机数来确定广告是否应该展示, 比如
- 随机数 ≤ 0.36: 允许弹出广告
- 随机数 > 0.36: 跳过本次广告展示
- 这里就是基于最终得出概率判断

这里就是服务端和客户端的大致对接流程规则, 之后就是服务端需要设计和开发业务功能

服务端设计

对于服务端来说肩负的任务可能比较多, 因为会涉及以下方面处理

  • 管理后台

  • 渠道打包

  • CDN部署

而应用表需要单独设计规划, 将母包所需的打包对应参数记录在一起, 母包就是对接自有SDK方便反编译重新打包的 APK, 首先设计数据应用表

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
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
# 首先是通用的单一用数据应用表
# 虽然工作大部分情况都在游戏公司, 但是我还是喜欢用 app 这个命令来包含游戏分类
# 这部分是我个人喜欢, 也可以按照自己需求改成 game_base 之类, 具体按照自身需求出发
create table if not exists nova_app_base
(
# 通用信息
app_id bigint not null auto_increment comment '数据主键, 用于标识递增',
app_ident varchar(32) not null comment '应用唯一标识字符串, 有时候应用不喜欢外部看到数值ID(会被爬虫遍历嗅探递增从而获取全部应用)',
app_key varchar(32) not null comment '客户端和服务端使用的授权等参数签名哈希值',
app_secret varchar(32) not null comment '服务端使用的支付等安全性高的签名哈希值',

# 基础信息
platform varchar(64) not null default '' comment '应用分配平台, android/ios/HarmonyOS/window/linux 标识',
name varchar(64) not null comment '应用的名称, 提供给应用初始化接口的信息初始化',
title varchar(255) not null default '' comment '应用的长标题, 用于显示应用的具体标题内容',
content varchar(255) not null default '' comment '应用的详细内容, 可以用于整体应用说明, 需要内部支持富文本渲染',
language varchar(8) not null default 'en' comment '应用的默认 i18n 语言模板, 比如 en = 英文, zh-CN = 简体中文, zh-TW = 繁体中文',
orientation tinyint not null default '0' comment '屏幕方向, 0 = 默认, 1 = 横屏, 2 = 竖屏',
keyboard varchar(255) not null default '' comment '应用类型(以英文逗号分割风格), 比如 "武侠,卡牌,MMO" 之类方便对外 SEO',


# 图标相关, 图片只需要保存相对路径即可, 方便后面上传 CDN
# 比如 https://site.example.com/static/images/test.png 就只需要保存 /static/images/test.png 路径部分
# 后面按照这部分路径保存到CDN的地址, 不建议采用过深的目录保存图片地址, 可能会导致数据长度不够被截断
icon varchar(255) not null default '' comment '小型图标图片地址, 使用 192x192 像素',
cover varchar(255) not null default '' comment '大型应用封面图片地址',
background varchar(255) not null default '' comment '大型应用背景图片地址',


# 母包文件资源信息
package_url varchar(255) not null default '' comment '母包保存地址, 默认提交 CDN 且只保存路径部分, 注意长度不要超过 255(也就是不要超过2层目录)',
package_md5 varchar(64) not null default '' comment '母包文件MD5',
package_size bigint unsigned not null default 0 comment '母包文件大小, 以 byte 为单位',
package_sign_id bigint not null default 0 comment '签名证书配置表对应ID, 用于获取对应表数据证书反编译打包成渠道包',

# 默认广告配置
# 一套母包会批量产出几十上百个渠道分包, 绝大多数渠道广告策略一致
# 用母包保存通用配置方便后续每个渠道上面都要创建新的广告策略
# 比如打出的渠道包开启的广告功能而对于广告策略字段全部留空, 下发直接复用将母包配置, 从而减少重复配置工作量
ad_status tinyint not null default '0' comment '广告启用状态, 0 代表可用, 大于0代表不同的关闭原因枚举',
ad_interval int unsigned not null default '3000' comment '全局广告最小弹窗间隔, 单位: 毫秒',
ad_session_max int unsigned not null default '10' comment '单次会话最大广告展示次数',

# 其他创建|更新信息
status tinyint not null default '0' comment '应用状态, 推荐采用 0 = 刚创建, 1 = 下架停用, 2 = 正常运营, 3 = 灰度测试',
create_time bigint not null comment '创建时间',
create_ip varchar(64) not null comment '创建IP',
update_time bigint not null default '0' comment '更新时间',
update_ip varchar(64) not null default '' comment '更新IP',


# 索引信息
unique key find_ident (app_ident),
primary key (app_id)
) comment '应用信息表, 默认只记录关联母包信息, 方便后续做分包'
engine = InnoDB
charset = utf8mb4
collate = utf8mb4_unicode_ci
auto_increment = 10000 # 从 10000 开始, 预留低于这个值的应用都是测试游戏应用
;

-- 注意: 全局有张 nova_sign_cert 证书表专门创建通用证书文件, 应该生成之后在后台下载并且用于母包签名
create table if not exists nova_sign_cert
(
-- 基础信息
cert_id bigint not null auto_increment comment '签名证书主键, nova_app_base.package_sign_id 关联此字段',
cert_platform varchar(64) not null comment '适配平台: android/ios/HarmonyOS 等',
cert_name varchar(255) not null comment '签名方案名称: 小米正式渠道签名/AppStore签名/鸿蒙官方签名',
cert_path varchar(255) not null comment '证书文件CDN相对路径 jks/p12',
cert_alias varchar(64) not null default '' comment '证书别名',

-- 密码相关
store_pwd text not null comment '密钥库密码, 一定要加密存储, 禁止明文',
key_pwd text not null comment '私钥密码, 一定要加密存储, 禁止明文',

-- 其他信息
remark varchar(255) null comment '适用渠道、版本备注',
status tinyint not null default '0' comment '应用状态, 推荐采用 0 = 启用, 1 = 测试, 2 = 禁用',
create_time bigint not null,
create_ip varchar(64) not null,
update_time bigint not null default 0,
update_ip varchar(64) not null default '',
primary key (cert_id)
) comment '渠道打包签名证书配置表'
engine = InnoDB
charset = utf8mb4
collate = utf8mb4_unicode_ci;


-- 插入测试证书数据
INSERT INTO nova_sign_cert (cert_platform, cert_name, cert_path, cert_alias,
store_pwd, key_pwd, remark, status,
create_time, create_ip, update_time, update_ip)
VALUES ('android',
'小米安卓正式渠道签名',
'/static/cert/android/xiaomi_release.jks',
'xiaomi_game',
'{证书密钥库密码}',
'{证书私钥密码}',
'适用于小米应用商店全渠道分包, 线上商用',
0,
1782130486136,
'192.168.1.100',
1782130486136,
'192.168.1.100');


-- 插入测试应用母包数据
INSERT INTO nova_app_base (app_ident, app_key, app_secret,
platform, name, title, content, language, orientation, keyboard,
icon, cover, background,
package_url, package_md5, package_size, package_sign_id,
ad_status, ad_interval, ad_session_max,
status, create_time, create_ip, update_time, update_ip)
VALUES ('s34x123x',
'7d2f9c4e8b1a3056zxcvbnmqwertyu',
's98df76gh34jk12lp09mn56rt23zx',
'android',
'Nova',
'Nova - 星际科幻卡牌手游',
'一款星际题材策略卡牌游戏,支持多渠道广告变现投放',
'zh-CN',
0,
'科幻,卡牌,策略,挂机',
'/static/app/10000/icon_192.png',
'/static/app/10000/cover_main.jpg',
'/static/app/10000/bg_promote.jpg',
'/static/app/10000/base_nova_2.1.0.apk',
'd41d8cd98f00b204e9800998ecf8427e',
125829120,
1,
0,
3000,
10,
3,
1782130486136,
'192.168.1.100',
1782130486136,
'192.168.1.100');


-- 联表查询校验关联关系是否正常
SELECT a.app_id,
a.app_ident,
a.name AS app_name,
a.package_sign_id,
a.ad_status,
a.ad_interval,
a.ad_session_max,
c.cert_id,
c.cert_name,
c.cert_platform
FROM nova_app_base a
LEFT JOIN nova_sign_cert c ON a.package_sign_id = c.cert_id
WHERE a.app_ident = 's34x123x';