Unity手游APK去广告实战:华为渠道包反编译、广告拦截与"网络不佳"弹窗修复全记录
本文仅用于技术学习与安全研究,请遵守相关法律法规,尊重开发者劳动成果。修改后的APK仅限个人学习测试使用。
摘要
朋友拿了一个华为渠道的手游 APK------《切割模拟器》(一款休闲解压类游戏,Unity 引擎,约 148MB)------希望帮忙去掉广告。整个过程中踩了不少坑:先是定位广告 SDK 的调用链,找到拦截点后成功去掉全部广告;随后又遇到改包后"一直提示网络不佳,请检查网络设置"的弹窗问题,最终定位到是华为渠道 SDK 的签名校验失败导致。本文完整记录了从工具链搭建、APK 结构分析、广告拦截点挖掘、smali 补丁编写,到重新打包签名验证的全过程,希望能给同样在做 APK 分析学习的同学一些参考。
一、环境与工具准备
1.1 原始材料
com.ylly.qgmnq.huawei.apk (约 147MB,华为渠道)
1.2 工具链
Windows 环境下需要以下工具:
| 工具 | 用途 | 说明 |
|---|---|---|
| JDK 17 (Temurin) | Java 运行环境 | apktool/jadx 均基于 Java |
| apktool 2.11.1 | APK 解码/回编译 | 将 dex 反汇编为 smali,改完再回编译 |
| jadx 1.5.1 | 反编译为 Java 源码 | 快速阅读逻辑,定位广告代码 |
| uber-apk-signer 1.3.0 | 重签名 + zipalign | 一步完成签名与对齐 |
踩坑提示:国内网络直连 GitHub 不稳定,可用
gh-proxy.com等镜像加速下载:
bashcurl -L -o apktool.jar "https://gh-proxy.com/https://github.com/iBotPeaches/Apktool/releases/download/v2.11.1/apktool_2.11.1.jar"
1.3 反编译
bash
# 1. 解码 APK(得到 smali 代码 + 资源 + Manifest)
java -jar apktool.jar d -f -o decoded com.ylly.qgmnq.huawei.apk
# 2. jadx 转 Java 源码(便于阅读)
jadx -d jadx_out --no-res com.ylly.qgmnq.huawei.apk
二、APK 结构初探
2.1 文件清单分析
bash
unzip -l com.ylly.qgmnq.huawei.apk | grep -E "classes[0-9]*\.dex|\.so$"
关键发现:
classes.dex 6.2MB ← Java 层代码(SDK 主要在这)
classes2.dex 325KB
libil2cpp.so 54MB ← Unity IL2CPP 游戏逻辑(C# 编译产物,不可直接改)
libunity.so 18MB
结论 :这是一个 Unity + IL2CPP 游戏。游戏本体逻辑在 libil2cpp.so 里(无法像 DEX 那样直接改),但广告 SDK 是纯 Java 实现,全部在 classes.dex 中------这就是突破口。
2.2 广告 SDK 识别
从 AndroidManifest.xml 中能明显看到华为广告 SDK 的痕迹:
xml
<meta-data android:name="com.huawei.hms.client.service.name:ads-banner"
android:value="ads-banner:13.4.82.300"/>
<meta-data android:name="com.huawei.hms.client.service.name:ads-splash"
android:value="ads-splash:13.4.82.300"/>
...
<activity android:name="com.huawei.openalliance.ad.activity.PPSLauncherActivity"/>
同时资源目录下有一堆 hiad_* 前缀的 drawable(华为广告素材),以及 splash_layout 等广告布局。
再结合 assets/lt_plugin_config.xml 确认了完整的 SDK 插件结构:
xml
<plugins>
<!-- 渠道插件:华为 -->
<plugin name="com.lt.sdk.channel.huawei.HuaweiChannel" type="channel" .../>
<!-- 广告插件:华为广告 -->
<plugin name="com.lt.sdk.ad.huawei.HuaweiAd" type="ad" .../>
<!-- 其他插件:游客登录、实名、统计 -->
<plugin name="com.lt.sdk.other.vl.VisitorLogin" type="other" .../>
</plugins>
这是一个典型的 MAR SDK(国内手游聚合 SDK) 架构:渠道、广告、账号、统计全部插件化。
三、广告调用链分析(核心)
3.1 从 Unity 桥接到广告展示
游戏 C# 代码(il2cpp)通过 Unity 的 AndroidJavaObject 机制调用 Java 层。入口是 MarMainActivity.ReciveMessageFromUnity(int type, String data):
java
// MarMainActivity.java(jadx 反编译)
public void ReciveMessageFromUnity(int Type, String data) {
FromUnity fromUnity = FromUnity.getTypeByKeys(Type);
switch (...) {
case 5: showInters(); break; // 插屏广告
case 6: showVideo(); break; // 激励视频
case 8: showBanner(num); break; // 横幅广告
case 9: showSplash(); break; // 开屏广告
case 27: showNativeInters(); break; // 原生插屏
...
}
}
完整调用链:
Unity C# (il2cpp)
→ MarMainActivity.ReciveMessageFromUnity()
→ MARGgPlatform.showXxx() // MAR SDK 广告网关
→ SDKAdPlatform.showXxx() // 广告平台分发
→ PluginManager → HuaweiAd // 华为广告插件
→ HuaweiAdCtrl (extends AdCtrl)
→ AdHandler.show() // ★ 广告处理器基类
→ 华为 HMS 广告 SDK → 渲染广告
3.2 关键发现:所有广告都要过 isReady()
通读 AdCtrl(广告控制器基类,1500 行)后,发现所有广告类型的展示路径都依赖同一个方法:
java
// AdHandler.java ------ 广告处理器基类
public boolean isReady() {
this.loadFailTimes = 0;
return this.loadState == AdLoadState.LOADED; // 广告加载成功才算就绪
}
它在全代码库中被引用 14 处,覆盖所有广告类型:
java
// 插屏广告
public boolean getIntersFlag() {
...
if (adHandler.isReady()) { hasReady = true; ... }
return canShow && hasReady;
}
// 激励视频
public boolean getVideoFlag() {
...
if (adHandler.isReady()) { hasReady = true; ... }
return canShow && hasReady;
}
// 横幅
public void showBanner(int pos) {
...
if (adHandler.isReady()) { hasReady = true; this.canShowBannerList.add(adHandler); }
if (canShow && hasReady) { this.bannerShowing.show(); }
}
// 弹窗广告(ejectAd)
public void ejectAd(AdType type, AdInstType instType, boolean isReplenish, IEjectAdCallback cb) {
...
if (adHandler.isReady()) { hasReady = true; canShowAdHandlers.add(adHandler); }
if (!hasReady) { cb.onEjectAdResult(false, "not exist load success ad"); return; }
...
}
这就是最优雅的拦截点 :只要让 isReady() 永远返回 false,所有广告都无法通过展示检查,而 API 结构完全保留,游戏不会崩溃。
3.3 为什么不直接删掉广告 SDK?
游戏 C# 代码(il2cpp)里仍会请求广告。若直接删掉华为广告 SDK 的类,运行时 ClassNotFoundException 直接闪退。"堵门"优于"拆门"------让广告永远"未就绪",比让它不存在更安全。
3.4 回调机制的妙用
拦截后游戏为什么不会卡死?因为 SDK 的失败回调仍然触发,游戏会走"广告未准备好/已关闭"的正常分支:
游戏请求插屏 → isReady()=false → showInters() 触发 onResult(1002, "广告已关闭")
→ MARSDK → callUnity(LoginSuccess 同通道) → 游戏 C# 继续流程 ✓
游戏请求激励视频 → isReady()=false → showVideo() 触发 onResult(1004, "广告未准备好")
→ 游戏弹提示后继续 ✓
四、smali 补丁编写(5处广告拦截)
4.1 核心补丁:AdHandler.isReady() 永远返回 false
找到反编译产物中的 smali:
smali
# decoded/smali/com/lt/sdk/base/plugin/ad/AdHandler.smali
.method public isReady()Z
.locals 3
const/4 v0, 0x0
iput v0, p0, Lcom/lt/sdk/base/plugin/ad/AdHandler;->loadFailTimes:I
iget-object v1, p0, Lcom/lt/sdk/base/plugin/ad/AdHandler;->loadState:Lcom/lt/sdk/base/model/AdLoadState;
sget-object v2, Lcom/lt/sdk/base/model/AdLoadState;->LOADED:Lcom/lt/sdk/base/model/AdLoadState;
if-ne v1, v2, :cond_0
const/4 v0, 0x1
:cond_0
return v0
.end method
替换为:
smali
.method public isReady()Z
.locals 1
const/4 v0, 0x0
return v0
.end method
smali 小知识:
return v0返回的是 v0 寄存器中的值;const/4 v0, 0x0把 0 赋给 v0。.locals 1声明 1 个局部寄存器(v0),参数 p0 是 this。注意局部寄存器数量必须 ≥ 实际使用的寄存器编号+1,否则回编译报错。
4.2 开屏广告补丁(2处)
开屏广告不走 isReady(),是直接加载 SplashView 的,需要单独处理:
① SplashAdActivity.onCreate() ------ 启动时的华为开屏广告页:
smali
.method protected onCreate(Landroid/os/Bundle;)V
.locals 1
.param p1, "savedInstanceState" # Landroid/os/Bundle;
invoke-super {p0, p1}, Landroid/app/Activity;->onCreate(Landroid/os/Bundle;)V
invoke-direct {p0}, Lcom/lt/sdk/ad/huawei/SplashAdActivity;->jump()V # 直接跳到游戏启动页
return-void
.end method
② HuaweiAdCtrl.testShowSplashAd() ------ 从后台切回前台时触发的开屏,改为直接恢复游戏:
smali
.method public testShowSplashAd()V
.locals 3
invoke-static {}, Lcom/lt/sdk/base/control/AdControl;->getInstance()Lcom/lt/sdk/base/control/AdControl;
move-result-object v0
const/4 v1, 0x1
invoke-virtual {v0, v1}, Lcom/lt/sdk/base/control/AdControl;->setInoutReason(I)V
invoke-static {}, Lcom/lt/sdk/base/control/AdControl;->getInstance()Lcom/lt/sdk/base/control/AdControl;
move-result-object v0
iget-object v1, p0, Lcom/lt/sdk/ad/huawei/HuaweiAdCtrl;->context:Landroid/app/Activity;
const/16 v2, 0xfc
invoke-virtual {v0, v2, v1}, Lcom/lt/sdk/base/control/AdControl;->sendActivityListener(ILandroid/app/Activity;)V
return-void
.end method
4.3 原生插屏弹窗补丁(2处)
游戏内还有一种"原生插屏"(伪装成原生 UI 的广告弹窗),它不经过 AdHandler ,是单例直接加载的。它的 getFlag() 决定能否展示:
smali
# SpecialNativeIntersAd.smali / SpecialNativeInters2Ad.smali
# 原始实现遍历 isLoadSuccess[] 数组,任一成功即返回 true
.method public getFlag()Z
.locals 1
const/4 v0, 0x0
return v0
.end method
4.4 补丁清单汇总
| # | 类 | 方法 | 修改 |
|---|---|---|---|
| 1 | AdHandler | isReady() | 恒 false(拦截横幅/插屏/视频/悬浮/原生等全部) |
| 2 | SplashAdActivity | onCreate() | 跳过启动开屏 |
| 3 | HuaweiAdCtrl | testShowSplashAd() | 跳过回前台开屏 |
| 4 | SpecialNativeIntersAd | getFlag() | 恒 false(原生插屏样式1) |
| 5 | SpecialNativeInters2Ad | getFlag() | 恒 false(原生插屏样式2) |
五、重新打包与签名
5.1 回编译
bash
java -jar apktool.jar b -f -o nopad-unsigned.apk decoded
5.2 签名(uber-apk-signer 一步完成)
bash
java -jar uber-apk-signer.jar -a nopad-unsigned.apk --overwrite
输出确认:
- zipalign verified
- signature verified [v2, v3]
六、踩坑:改包后"一直提示网络不佳"
6.1 现象
装上修改版后,游戏反复弹出"网络不佳,请检查网络设置",点"确定"重试又弹,无限循环。
6.2 排查过程
第一步:全代码库搜索该字符串
bash
grep -rn "网络不佳" jadx_out/sources/
只有一处:
java
// HuaWeiRNDialog.java
content.setText("网络不佳,请检查网络设置");
第二步:查这个 Dialog 的触发点
它是华为渠道 SDK(账号/游戏服务)在 init / login / 实名验证 失败时弹的。也就是说------根本不是广告加载失败,而是华为渠道初始化失败。
第三步:确认根因
华为游戏服务(HMS JosApps)初始化时会校验应用签名是否与华为应用市场登记的一致。我们改包后用调试签名重新签名,签名不匹配 → 初始化返回错误码 → 弹窗 → 点击重试 → 再次失败 → 死循环。
再交叉验证:IL2CPP 的 global-metadata.dat(包含所有 C# 字符串字面量)里搜"网络"相关字符串,一个都没有,进一步证明提示来自 Java 侧 SDK,而非游戏逻辑。
6.3 修复思路
改包后的 APK 本质上是"华为账号体系不可用"的,硬走华为登录永远失败。正确做法是让 SDK 不再依赖华为服务器:
- 禁弹窗 :
HuaWeiRNDialog.showTip()→ 空操作 - 初始化永远成功 :
HuaweiSDK.initGameService()→ 不再调用 HMS,直接置initSuccess=true并上报onInitResult(10) - 登录走游客回退 :
HuaweiSDK.login()→ 直接回调空结果,触发 SDK 内置的游客登录 插件(VisitorLogin已在插件配置中注册,是 SDK 的标准降级机制)
smali
# HuaweiSDK.login() 补丁
.method public login()V
.locals 2
iget-object v0, p0, Lcom/lt/sdk/channel/huawei/HuaweiSDK;->channelListener:Lcom/lt/sdk/base/plugin/channel/IChannelListener;
if-eqz v0, :cond_skip
const-string v1, ""
const-string v2, "visitor login"
invoke-interface {v0, v1, v2}, Lcom/lt/sdk/base/plugin/channel/IChannelListener;->onLoginResult(Ljava/lang/String;Ljava/lang/String;)V
:cond_skip
return-void
.end method
6.4 修复后的自动登录链路
启动 → 隐私协议 → (跳过广告开屏) → 游戏启动页 → 主界面
↓
华为SDK init → 直接成功 → SDK自动触发登录(onInitResult(10) 钩子)
↓
login → 空结果 → handleChannelLoginResult 检测到游客插件
↓
游客登录成功 (visitorId/visitorToken) → callUnity(LoginSuccess) → 进入游戏
这个链路是 SDK 内部自动完成的:
SDKPlatform.setSelfSDKListener()里注册了onInitResult(10)后自动调用PluginManager.login()的逻辑。所以改完这三个方法后,不需要游戏端做任何配合。
七、最终验证
7.1 补丁验证(对最终 APK 二次反编译)
用 apktool 重新解码签名后的 APK,逐一确认 8 处补丁都在:
[广告拦截 - 5处]
1) AdHandler.isReady → return false
2) SpecialNativeIntersAd.getFlag → return false
3) SpecialNativeInters2Ad.getFlag → return false
4) HuaweiAdCtrl.testShowSplashAd → 跳过开屏
5) SplashAdActivity.onCreate → 直接 jump
[网络不佳弹窗修复 - 3处]
6) HuaWeiRNDialog.showTip → no-op
7) HuaweiSDK.initGameService → 永远成功
8) HuaweiSDK.login → 游客登录回退
7.2 签名与完整性验证
bash
# zip 完整性
unzip -t final.apk → OK
# 签名 + 对齐
uber-apk-signer -a final.apk --onlyVerify
→ zipalign verified
→ signature verified [v2, v3]
# 文件数与资源一致性(原包 837 个文件,改后仍 837 个)
7.3 效果
- ✅ 开屏广告:直接跳过
- ✅ 插屏/弹窗广告:不再弹出
- ✅ 激励视频:不再播放
- ✅ 横幅/悬浮/原生广告:全部不显示
- ✅ "网络不佳"弹窗:消除
- ✅ 游戏正常进入(游客模式)
八、总结与思考
-
找对拦截点比蛮力删除重要 :一个
isReady()方法拦截了 90% 的广告类型,这就是"关键路径分析"的价值------先梳理调用链,找到所有广告类型的公共汇聚点。 -
改包的本质是"与签名共存亡":任何渠道包改签名后,渠道 SDK(华为、小米、OPPO 等)的账号/支付/云服务都会失效,这是平台侧的校验,无法绕过(也不应该绕过)。本文的做法是让 SDK 走自身的降级路径(游客登录),而不是伪造渠道凭据。
-
IL2CPP 游戏改 Java 层的优势:游戏逻辑在原生库中无法修改,但 SDK 层是 Java,改动后对游戏透明------这是此类"改 SDK 不改游戏"方案的通用价值。
-
工具链:apktool(smali 级修改)+ jadx(源码阅读)+ uber-apk-signer(签名)是 APK 修改的黄金组合,全部开源免费。
附:工具下载地址
| 工具 | 地址 |
|---|---|
| apktool | https://github.com/iBotPeaches/Apktool/releases |
| jadx | https://github.com/skylot/jadx/releases |
| uber-apk-signer | https://github.com/patrickfav/uber-apk-signer/releases |
| Temurin JDK | https://adoptium.net/ |
再次声明:本文技术内容仅用于学习研究。请勿将修改后的 APK 用于商业传播或侵权用途,支持正版,尊重每一位开发者的劳动。
详细APK见资源