手机游戏推广方案落地:渠道归因与ROAS对账数据链路
2026/9/17 21:40:15 网站建设 项目流程

简介:这份 PDF 文档聚焦手机游戏推广方案的完整策划思路,面向手游运营、市场推广及棋牌类游戏平台的从业者与产品新人。内容围绕平台定位、推广策略、推广目标与四阶段实施计划展开,涵盖 SEO 优化、关键词密度控制、META 标签与页面标题设计、站内地图制作、有效内容引入、搜索引擎与导航站提交收录,以及博客、论坛、邮件营销、软文新闻、QQ 群、微信等渠道的整合推广,并延伸至稳定期的友情链接、线上活动和线下棋牌比赛运营。资源共 1 个 PDF 文件,压缩包约 381KB,单文件结构便于集中查阅与打印研讨。目前已有 49 人学习下载,适合需要搭建推广框架、梳理阶段目标或参考实战节奏的读者,可作为游戏平台推广方案撰写的模板与自查清单。

1. 手机游戏推广方案落地,卡点不在方案而在数据链路

买量团队交上来一份《手机游戏推广方案介绍.pdf》,里面写满了渠道预算、素材方向、排期节奏。工程侧拿到手第一件事不是照着排期开干,而是问三句:渠道号从哪来、激活和付费怎么归到渠道、每天的花费和回收怎么对上账。这三件事没打通,PDF 里的预算表就只是一张纸。

真正能跑起来的推广方案,本质是一条「渠道投放 → 落地页或商店 → 安装激活 → 归因回传 → 数据聚合 → 报表输出」的链路,中间每一段都得有可复现的工程实现。下面按这条链路拆开讲:渠道分包与归因接口怎么设计、客户端埋点和服务端事件表怎么落、CAC 与 ROAS 的对账 SQL 怎么写,以及最后怎么把核算结果自动导出成一份可交付的 PDF 文档。

2. 渠道分包与归因回传接口:手机游戏推广方案的数据入口

2.1 Android 渠道号注入:META-INF 追加为什么现在不灵了

早些年流传最广的做法,是往已经签好名的 APK 的META-INF目录里塞一个空文件,文件名就是渠道号,比如channel_huawei。读取时遍历 ZIP 条目,匹配前缀即可。这套办法在只启用 v1(JAR signing)签名的年代确实好使,因为 v1 只校验MANIFEST.MF*.SF*.RSA这几类文件,多出来的空文件不影响校验。

问题出在 v2/v3 签名上。这两种方案对整包内容做摘要,签名块存放在 ZIP 的注释区。签名之后再往包里追加文件,摘要立刻对不上,安装时直接报签名校验失败。所以只要你的包启用了 v2/v3 —— 现在几乎所有上架包都启用 —— 事后追加这条路就走不通了,必须改成签名之前注入。

我一般用构建期脚本处理,思路是拿未签名包,写入assets/channel,再统一签名:

#!/usr/bin/env bash # 构建期分包:签名之前注入渠道号,避免破坏 v2/v3 签名摘要 set -euo pipefail BASE=app-release-unsigned.apk for ch in huawei xiaomi oppo vivo; do rm -rf build_pkg && mkdir -p build_pkg/assets printf '%s' "$ch" > build_pkg/assets/channel # 文件内容即渠道号 cp "$BASE" "dist/app-${ch}-unsigned.apk" (cd build_pkg && zip -q "../dist/app-${ch}-unsigned.apk" assets/channel) apksigner sign --ks release.jks --ks-pass env:KS_PASS \ --out "dist/app-${ch}.apk" "dist/app-${ch}-unsigned.apk" echo "signed -> dist/app-${ch}.apk" done

脚本里有三处值得说明。printf而不是echo,是为了避免在文件末尾多写一个换行,读取时还得trimzip -q是往已有归档里更新条目,不重新打包整个 APK,所以耗时很短,几十个渠道一两分钟内跑完。apksigner必须放在最后一步,--out写成带渠道号的文件名,方便直接进分发目录。

客户端读取只在冷启动时做一次:

object ChannelReader { private const val DEFAULT = "official" fun read(context: Context): String = runCatching { // sourceDir 指向已安装的 APK 路径,用 ZipFile 随机读,不必解压 ZipFile(context.applicationInfo.sourceDir).use { zip -> val entry = zip.getEntry("assets/channel") ?: return@use DEFAULT zip.getInputStream(entry).bufferedReader().readText().trim().ifEmpty { DEFAULT } } }.getOrDefault(DEFAULT) }

几种渠道号获取方式的取舍:

方案注入时机对签名的影响适用场景
META-INF 空文件追加签名之后v2/v3 摘要失配,安装失败仅 v1 签名的存量包
assets/channel 注入签名之前无影响,正常重签自建分发、国内各应用商店
Play Install Referrer无需分包无影响单一商店渠道,靠 utm 参数区分
落地页透传 + 首启上报无需分包无影响H5 拉起、短链分发

注意:同一个包被不同渠道重复上架时,渠道号必须跟着包走,不能靠首次启动时问服务端要。否则用户换网、首启失败时会掉进 official 兜底,激活数据就归错渠道了。

2.2 iOS 侧把 6 bit 转化值分配好

iOS 拿不到点击级 ID,回传由系统侧发起,你能控制的只有一个 6 bit 的转化值,取值范围 0 到 63。63 个格子装不下完整事件流,只能编码最有投放价值的信号。

常见做法是按位域切分:低位放首日是否完成关键行为,中间放付费档位,高位放注册深度。

// 6 bit 位域:bit5-4 注册深度(0-3) | bit3-1 付费档位(0-7) | bit0 是否完成新手引导 func makeConversionValue(registerDepth: Int, payTier: Int, finishedTutorial: Bool) -> Int { let depth = min(max(registerDepth, 0), 3) let tier = min(max(payTier, 0), 7) let flag = finishedTutorial ? 1 : 0 return (depth << 4) | (tier << 1) | flag // 结果落在 0...63 } // 事件发生就回写,窗口期内允许多次更新,系统取最后一次 SKAdNetwork.updateConversionValue( makeConversionValue(registerDepth: 2, payTier: 3, finishedTutorial: true) )
位段宽度含义取值
bit 5-42注册深度(游客 / 绑定 / 实名 / 付费)0-3
bit 3-13首日付费档位(6 / 30 / 68 / 128 / 328 / 648 / 648+ / 0)0-7
bit 01是否完成新手引导0-1

提示:转化值更新有次数和时间窗限制,别每打一个点就写一次。把回写点收敛到「完成新手引导」「首次付费成功」「注册完成」这三个里程碑上,投放侧看得懂,也不会白白浪费更新配额。

较新的 SKAdNetwork 版本还引入了粗粒度值和多档回传窗口,字段细节以官方文档为准,但位域分配这套思路不变:先确定要回答哪几个投放问题,再决定哪几位承载哪个信号。

2.3 回传接口的参数表、HMAC 签名与幂等写入

归因回传接口是推广链路里唯一会被外部系统高频调用的入口,参数一旦定错,后面所有报表都要返工。我一般固定成下面这套:

参数类型必填说明
click_idstring点击侧生成的唯一 ID,回传时原样带回,是对账主键
campaign_idstring广告计划 ID
adgroup_idstring广告组 ID,做素材级分析时用
creative_idstring素材 ID
oaid / idfastring设备标识,国内走 OAID,iOS 走 IDFA(需授权)
event_namestringactivate / register / pay
event_timeint事件发生的秒级时间戳,取服务端时间
revenuedecimal付费金额,仅 pay 事件
currencystringCNY / USD
sigstringHMAC-SHA256 签名

签名校验的实现要注意字段顺序问题,两端必须约定同一套拼接规则:

import hmac, hashlib SECRET = b"ad_platform_shared_secret" # 线下交换,绝不能下发到客户端 def sign(payload: dict) -> str: # 按 key 字典序拼接,两端字段顺序不一致也不会验签失败 raw = "&".join(f"{k}={payload[k]}" for k in sorted(payload) if k != "sig") return hmac.new(SECRET, raw.encode(), hashlib.sha256).hexdigest() def verify(payload: dict) -> bool: return hmac.compare_digest(sign(payload), payload.get("sig", ""))

hmac.compare_digest而不是==,是为了避免比较耗时泄露签名信息。sorted()保证拼接顺序稳定。SECRET只存在服务端环境变量里,客户端只需带上平台下发的click_id

验签通过之后立刻落库,重复回传交给数据库挡:

CREATE TABLE ad_postback ( id BIGSERIAL PRIMARY KEY, click_id TEXT NOT NULL, event_name TEXT NOT NULL, event_time BIGINT NOT NULL, revenue NUMERIC(12,2) DEFAULT 0, raw_body JSONB NOT NULL, -- 原始报文留着,排错时救命 created_at TIMESTAMPTZ DEFAULT now(), UNIQUE (click_id, event_name, event_time) );

raw_body这一列非常值得留。投放平台改字段、签名规则变动、金额对不上,最后一招都是把原始报文翻出来重放一遍。

# 本地联调:确认接口能收、能验签、能幂等 curl -X POST https://track.example.com/v1/postback \ -H 'Content-Type: application/json' \ -d '{"click_id":"c_8f3a91","campaign_id":"cmp_20481","event_name":"pay", "event_time":1730000000,"revenue":"68.00","currency":"CNY","sig":"<hex>"}'

同一条命令跑两次,第二次应该返回成功但影响行数为 0,这就说明幂等生效了。

3. 从客户端埋点到服务端事件表:推广数据的采集与幂等

3.1 客户端埋点 SDK 的最小可用形态

推广方案要求的指标是 D0、D7 这类短周期口径,对埋点的实时性要求不低,但完全没必要一上来就上重型方案。一款中重度手游,最朴素的「落盘 + 攒批 + 重试」就够用。

class EventTracker( private val queueFile: File, // 本地队列文件,建议放 filesDir 而非 cacheDir private val endpoint: String, private val channel: String, private val scope: CoroutineScope, ) { private val client = OkHttpClient.Builder() .connectTimeout(5, TimeUnit.SECONDS) .build() private val gson = Gson() // 先落盘再入内存,进程被杀最多丢一次尚未 flush 的批次 fun track(name: String, props: Map<String, Any?>) { val event = mapOf( "event_id" to UUID.randomUUID().toString(), // 幂等键,服务端靠它去重 "event_name" to name, "event_time" to System.currentTimeMillis(), "channel" to channel, "props" to props, ) synchronized(this) { queueFile.appendText(gson.toJson(event) + "\n") } scope.launch { flushIfNeeded() } } private suspend fun flushIfNeeded() = withContext(Dispatchers.IO) { val lines = synchronized(this) { val all = queueFile.readLines() if (all.size < 20) return@withContext // 攒够 20 条再发,省流量也省服务端连接 queueFile.writeText("") // 先清空,发送失败再补回 all } val body = lines.joinToString(prefix = "[", postfix = "]", separator = ",") runCatching { client.newCall( Request.Builder().url(endpoint) .post(body.toRequestBody("application/json".toMediaType())) .build() ).execute().use { if (!it.isSuccessful) error("http ${it.code}") } }.onFailure { synchronized(this) { queueFile.appendText(lines.joinToString("\n") + "\n") } } } }

三个参数值得单独讲。queueFile放在filesDir下,cacheDir会被系统在空间紧张时清理掉,付费事件丢了就再也补不回来。批量阈值 20 是个经验值,低于 10 条请求过密,高于 50 条则网络一抖动丢的就多。event_id用 UUID 而不是自增,是为了让重试报文天然可去重,服务端不需要维护会话。

注意:重试一定要退避。连续失败时按 5s、15s、60s、300s 递增,不要写成固定间隔重发,否则弱网设备会把电量耗光,玩家投诉比数据丢几条严重得多。

3.2 事件表分区与唯一索引的取舍

服务端收下的事件直接进明细表,用时间分区,按周或按天都行。分区的好处是过期数据直接DROP PARTITION,比DELETE快几个数量级。

CREATE TABLE ods_game_event ( event_id TEXT NOT NULL, event_name TEXT NOT NULL, event_time TIMESTAMPTZ NOT NULL, device_id TEXT NOT NULL, channel TEXT NOT NULL, app_version TEXT, props JSONB NOT NULL DEFAULT '{}', dt DATE NOT NULL ) PARTITION BY RANGE (event_time); CREATE TABLE ods_game_event_2025w02 PARTITION OF ods_game_event FOR VALUES FROM ('2025-01-06') TO ('2025-01-13'); -- 分区表上的唯一索引必须包含分区键,所以幂等键是 (event_id, event_time) 组合 CREATE UNIQUE INDEX uq_game_event ON ods_game_event (event_id, event_time);

写入语句用ON CONFLICT DO NOTHING把去重彻底交给数据库:

INSERT INTO ods_game_event (event_id, event_name, event_time, device_id, channel, app_version, props, dt) VALUES ($1, $2, to_timestamp($3 / 1000.0), $4, $5, $6, $7::jsonb, (to_timestamp($3 / 1000.0) AT TIME ZONE 'Asia/Shanghai')::date) ON CONFLICT (event_id, event_time) DO NOTHING;

dtAT TIME ZONE 'Asia/Shanghai'显式换算,这一点非常关键。客户端上报的是毫秒时间戳,天然是 UTC 语义,如果建表时用event_time::date,等于按 UTC 切日,国内投放的日报表会整体错位八小时,晚上八点后的付费全被算到第二天。

3.3 把激活、注册、付费聚到渠道维度的 SQL

数据齐了之后,第一张要出的表是渠道维度的漏斗。用FILTER子句一次扫表出三个指标,比写三个子查询快得多:

WITH base AS ( SELECT channel, device_id, MIN(event_time) FILTER (WHERE event_name = 'activate') AS activate_at, MIN(event_time) FILTER (WHERE event_name = 'register') AS register_at, MIN(event_time) FILTER (WHERE event_name = 'pay') AS first_pay_at FROM ods_game_event WHERE dt BETWEEN DATE '2025-01-06' AND DATE '2025-01-12' AND event_name IN ('activate', 'register', 'pay') GROUP BY channel, device_id ) SELECT channel, COUNT(*) FILTER (WHERE activate_at IS NOT NULL) AS activates, COUNT(*) FILTER (WHERE register_at IS NOT NULL) AS registers, COUNT(*) FILTER (WHERE first_pay_at IS NOT NULL) AS payers, ROUND( COUNT(*) FILTER (WHERE first_pay_at IS NOT NULL)::numeric / NULLIF(COUNT(*) FILTER (WHERE activate_at IS NOT NULL), 0), 4 ) AS pay_rate FROM base GROUP BY channel ORDER BY activates DESC;

base这一层的作用是把「设备级」的首次行为时间抽出来,避免同一天内的多条日志被重复计数。NULLIF防的是新渠道零激活时除零报错。pay_rate近似等于首日付费率,但要留意:这里统计的是首日付费用户,跨天回流的付费玩家不会出现在这一行,D1、D7 的付费率得换一套窗口。

4. 手机游戏推广方案的效果核算:CAC、ROAS 与花费对账

4.1 四个核心指标的口径定义

口径不统一是对账吵架的根源。同一份推广方案,投放说 ROAS 0.85,财务说 0.62,十有八九是分子分母的时间窗没对齐。

指标公式分子口径分母口径容易踩的坑
CAC花费 / 激活数渠道后台实际扣费,已扣返点归因到该渠道的激活数分母没剔自然量,CAC 会显得很漂亮
ROAS_D77 日累计收入 / 同期花费激活后 7 个自然日内的全部付费同一激活日的花费分母用整月花费,比值直接失真
首日付费率首日付费人数 / 当日激活数激活当日付费人数当日激活数跨天回流的付费不算 D0
ARPU收入 / 激活数全部收入激活数含未付费用户,别和 ARPPU 混用

提示:返点一定要计入花费。很多团队投放后台看的是税前金额,财务账上是扣点后的实际支出,两边差 5% 到 15% 很常见,最后所有 ROAS 都对不上。

4.2 花费与回收的关联 SQL

花费表来自渠道后台的日汇总,收入表来自自己的事件表,两边靠「渠道 + 激活日」对齐:

WITH spend AS ( SELECT dt, channel, SUM(cost) AS cost FROM ods_ad_spend WHERE dt BETWEEN DATE '2025-01-06' AND DATE '2025-01-12' GROUP BY dt, channel ), rev AS ( -- ods_activate_daily 是设备级激活快照,含 device_id / activate_at / channel SELECT a.channel, a.dt AS activate_dt, SUM(e.revenue) AS revenue_d7 FROM ods_activate_daily a JOIN ods_game_revenue e ON e.device_id = a.device_id AND e.event_time >= a.activate_at AND e.event_time < a.activate_at + INTERVAL '7 days' WHERE a.dt BETWEEN DATE '2025-01-06' AND DATE '2025-01-12' GROUP BY a.channel, a.dt ) SELECT s.channel, s.dt, s.cost, COALESCE(r.revenue_d7, 0) AS revenue_d7, ROUND(COALESCE(r.revenue_d7, 0) / NULLIF(s.cost, 0), 3) AS roas_d7 FROM spend s LEFT JOIN rev r ON r.channel = s.channel AND r.activate_dt = s.dt ORDER BY s.dt, s.channel;

这里用LEFT JOIN而不是INNER JOIN,是为了让「有花费但当天零回收」的渠道行依然出现。零回收本身就是最重要的信号,被 join 过滤掉就等于把问题藏起来了。INTERVAL '7 days'的起点是每个设备各自的激活时刻,不是自然日边界,这样算出来的才是真正的 7×24 小时窗口。

4.3 花费和回收对不上账时的排查顺序

差额在 10% 以内可以接受,超过 30% 就要按这个顺序往下查:

  1. 先看回传量。渠道后台的激活数和自己库里的激活数摆在一起,如果连量级都对不上,问题在回传成功率,去翻接口的失败日志和重试队列,不用往下走。
  2. 再查时区。渠道后台多数按各自平台时区切日,自己的dt走的是Asia/Shanghai。把两边同一天的明细拉出来按小时分布看一眼,整块错位八小时基本就是这个原因。
  3. 查归因窗口。平台按点击后 7 天归因算激活,自己如果做的是「激活即绑渠道」,会多出一批自然量设备,导致分母偏大、CAC 偏低。
  4. 查重复计数。同一台设备卸载重装、换渠道包覆盖安装,会不会重复记激活。用device_id做一次COUNT(*) OVER (PARTITION BY device_id)排序,重复的排前面。
  5. 最后查币种和返点。美元账户换算人民币用了哪天的汇率,返点有没有计进去。

把这五步做成一个固定脚本,每次对账跑一遍,比每次凭经验猜快得多。

5. 把核算结果导出成《手机游戏推广方案介绍.pdf》的排版技巧

5.1 WeasyPrint 模板里必须写死的三处 CSS

用 SQL 出完数,最后一步是产出一份能直接发出去的 PDF。常见做法是渲染 HTML 再转 PDF,比直接用绘图库排版省事,中文字体也更好处理。

from weasyprint import HTML from jinja2 import Template TPL = """ <html><head><meta charset="utf-8"><style> @page { size: A4; margin: 18mm 14mm; @bottom-center { content: "第 " counter(page) " 页 / 共 " counter(pages) " 页"; font-family: "Noto Sans CJK SC"; font-size: 9pt; } } body { font-family: "Noto Sans CJK SC", sans-serif; font-size: 10.5pt; line-height: 1.6; } table { width: 100%; border-collapse: collapse; } th, td { border: 1px solid #ccc; padding: 4px 6px; font-size: 9pt; } thead { display: table-header-group; } /* 跨页时表头自动重复 */ tr { page-break-inside: avoid; } /* 单行不被拆到两页 */ </style></head><body> <h1>{{ title }}</h1> <table><thead><tr><th>渠道</th><th>花费</th><th>激活</th><th>ROAS D7</th></tr></thead> <tbody> {% for r in rows %} <tr><td>{{ r.channel }}</td><td>{{ '%.2f'|format(r.cost) }}</td> <td>{{ r.activates }}</td><td>{{ '%.3f'|format(r.roas_d7) }}</td></tr> {% endfor %} </tbody></table> </body></html> """ html = Template(TPL).render(title="手机游戏推广方案介绍", rows=rows) HTML(string=html, base_url=".").write_pdf("手机游戏推广方案介绍.pdf")

三处 CSS 是必须的。font-family明确写Noto Sans CJK SC,容器里没装 CJK 字体时中文会渲染成方块,Linux 上先跑apt install fonts-noto-cjkthead { display: table-header-group }让长表格跨页时自动重复表头,几十行渠道明细没有它是没法看的。tr { page-break-inside: avoid }防止某一行被从中间劈开,数字被拆到两页是最容易被一眼看出的低级错误。页脚用counter(page)counter(pages)由 CSS 自动填,不要自己在 Python 里算页码。

5.2 生成之后的三项自检

模板渲染成功不等于文档正确,生成后必过三道检查:

# 一、页数对不对、有没有嵌入中文字体 pdfinfo 手机游戏推广方案介绍.pdf | grep -E 'Pages|Page size' pdffonts 手机游戏推广方案介绍.pdf | awk 'NR<=3 || /CJK|Noto/'
# 二、回读核对,防止数字列渲染成空白 import pdfplumber with pdfplumber.open("手机游戏推广方案介绍.pdf") as pdf: table = pdf.pages[-1].extract_table() assert table and len(table) > 1, "表格没有渲染出来" assert table[1][1].strip() != "", "花费列是空的"

第三项是格式检查:负数金额、千分位、小数点后位数在跨渠道表格里保持一致,68.0068混排会让投放侧怀疑数据来源。把pdffontsextract_table这两步串进 CI 流水线,每次跑完方案自动校验一遍,就不会再出现页脚是空的、中文是方块、花费列少一截这类返工。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询