Godot 4.6 + Android 真机跑通 AdMob 广告(横幅 / 插页 / 奖励)

这份文档里的每一步、每一个坑,都是在真机上一遍遍试出来的,不是照抄文档写的。
实测机型:Redmi 23013RK75C(Android 13 / Adreno 730),Godot 4.6.stable,
插件:Poing Studios godot-admob v4.0.1。

为什么值得专门写一份:这个插件在 Godot 4.6 上有 三个必须手动打补丁 的地方,
不打的话要么 403、要么 JNI 报错、要么横幅完全静默失败(最难查,本文重点)。


一、先说结论:要打三个补丁

# 症状 根因 补丁位置
1 广告请求返回 403,拿不到广告 插件写死 play-services-ads:24.9.0,该版本已被 Google 停服 addons/admob/android/bin/ads/poing_godot_admob_ads.gd
2 报错 Array[String] does not have the same element type Godot 4.6-stable 引擎 bug:java_class_wrapper.cpp:202 把 && 写成 \|\|,任何 typed Array 传 String[] 都失败 src/api/core/AdRequest.gd + 4 处调用点
3 横幅既不成功也不失败,SDK 一个网络包都不发 GDScript int 传给 Java 时被装箱成 Long,插件 aar 里是 checkcast Integer,抛异常后被 JNI 静默吞掉 src/api/AdView.gd

补丁 1、2 相对好查(有明确报错)。补丁 3 才是真正卡人的地方,下面第五节详细讲。


二、环境清单

项目 版本 / 说明
Godot 4.6.stable(用 _console.exe 版本,命令行能看到完整输出)
AdMob 插件 Poing Studios godot-admob v4.0.1
play-services-ads 25.5.0(跟随 Maven latest,不能用插件默认的 24.9.0)
渲染器 Mobile(Vulkan)
Android 构建模板 必须安装(Gradle 构建,约 200MB)
adb Android SDK platform-tools
测试机 开启 USB 调试,屏幕保持解锁

三、第 1 步:建项目

3.1 路径必须全 ASCII

✅ C:\Users\xxx\Documents\AdMobPortrait
❌ C:\Users\xxx\Documents\实验广告

Godot 导出到中文路径会失败,这是硬限制。

3.2 project.godot 的关键项

config/name="AdMobPortrait"
run/main_scene="res://Main.tscn"
config/features=PackedStringArray("4.6", "Mobile")
config/icon="res://icon.svg"          ; 不写会报 "No project icon specified"

[autoload]
AdManager="*res://AdManager.gd"

[display]
window/size/viewport_width=720
window/size/viewport_height=1280
window/handheld/orientation=1          ; 1 = 竖屏
window/stretch/mode="canvas_items"
window/stretch/aspect="expand"

[rendering]
renderer/rendering_method="mobile"
renderer/rendering_method.mobile="mobile"
textures/vram_compression/import_etc2_astc=true   ; ★ 缺了导出会失败

⚠ 坑:导出报 due to configuration errors: 但消息是空的

ERROR: Cannot export project with preset "portrait" due to configuration errors:
(后面什么都没有)

根因在引擎 platform/android/export/export_plugin.cpp:3047,
has_valid_project_configuration() 里有一段不写任何错误信息就直接置 false 的判定:

if (!ResourceImporterTextureSettings::should_import_etc2_astc()) {
    valid = false;      // ← 没有 r_error,所以错误消息为空
}

解决:补上 textures/vram_compression/import_etc2_astc=true。


四、第 2 步:装插件

4.1 放插件并启用

把 addons/admob 整个目录放进项目,然后 project.godot 加:

[editor_plugins]
enabled=PackedStringArray("res://addons/admob/plugin.cfg")

4.2 ★ 必须生成一次全局类名缓存

新建项目第一次打开时,脚本里的 AdView / MobileAds / AdRequest 等全局类名
全部报 “not declared”,因为 .godot/global_script_class_cache.cfg 还没生成。

godot --headless --editor --quit

跑一次(会自动生成缓存然后退出),再打开编辑器/导出就正常了。

4.3 安装 Android 构建模板

编辑器菜单:项目 → 安装 Android 构建模板,会生成 android/build/(约 200MB)。

如果从别的项目拷现成的 android/ 目录,要剔除 build/build、.gradle,
但 assetPackInstallTime 是模板自带的,别删。


五、第 3 步:三个必打的补丁 ★核心章节

补丁 1 —— play-services-ads 升到 25.5.0(解决 403)

文件:addons/admob/android/bin/ads/poing_godot_admob_ads.gd

# 原值 24.9.0 已被 Google 停服,服务端直接返回 403
var _dependency_library := ["com.google.android.gms:play-services-ads:25.5.0"]

版本号别写死,去
https://dl.google.com/dl/android/maven2/com/google/android/gms/play-services-ads/maven-metadata.xml
看 <latest> 是多少,跟着走。

补丁 2 —— typed Array 换 PackedStringArray(解决 JNI 报错)

根因:Godot 4.6-stable 的 platform/android/java_class_wrapper.cpp:202:

if (arr.is_typed() && (builtin != STRING || builtin != STRING_NAME))
//                                      ^^ 应该是 &&

|| 导致这个条件恒为真,于是任何 typed Array 传 String[] 都被拒。
(master / 4.7-dev 已修正为 &&。)

改法 —— 在 src/api/core/AdRequest.gd 加一个转换函数:

func jni_keywords() -> PackedStringArray:
    return PackedStringArray(keywords)

然后把 4 处调用点 的第 3 个参数改掉:

文件 行 改法
src/api/AdView.gd 89 ... ad_request.jni_keywords())
src/api/InterstitialAdLoader.gd 44 ad_request.jni_keywords()
src/api/RewardedAdLoader.gd 44 ad_request.jni_keywords()
src/api/RewardedInterstitialAdLoader.gd 44 ad_request.jni_keywords()

另外 src/api/MobileAds.gd:42 也要改:

_plugin.set_request_configuration(
    request_configuration.convert_to_dictionary(),
    PackedStringArray(request_configuration.test_device_ids))   # ← 加 PackedStringArray

补丁 3 —— Integer 装箱(解决横幅完全静默失败)★最难查

症状

插页、奖励都正常,只有横幅:发出加载请求之后,
既没有 on_ad_loaded,也没有 on_ad_failed_to_load,SDK 一个包都不发。
日志里干干净净,什么都不报错。

排查三步法(这套手法可以复用到别的插件问题)

第 1 步:对照插件日志找断点

adb logcat -d -v brief | grep -i "poing-godot-admob\|godot   "

正常应该看到 safeInsetRect 之后紧跟着:

D/poing-godot-admob: Safe Area of screen: Rect(...).
D/poing-godot-admob: marginTop: 0

如果 Safe Area of screen: 从没出现,说明 Banner 构造中途就断了。

第 2 步:看视图层级,确认 AdView 到底建出来没有

adb shell dumpsys activity top

往下找 godot_fragment_container,正常应该能看到
com.google.android.gms.ads.AdView。看不到就说明 addView 那段没执行。

第 3 步:反编译 aar 看字节码

unzip -o addons/admob/android/bin/ads/libs/poing-godot-admob-ads-release.aar
cd <解压目录> && unzip -o classes.jar
javap -p -c com/poingstudios/godot/admob/ads/adformats/Banner.class

在 Banner.<init> 的字节码里会看到:

63: checkcast  java/lang/Integer     // ad_position

convertToAdSize 里也有两处(width / height)。

根因

GDScript int  ──写进 Dictionary──▶  Godot 装箱成 java.lang.Long
                                            │
                                   Java 侧 checkcast java/lang/Integer
                                            │
                                   ClassCastException
                                            │
                                   被 JNI 层静默吞掉 → Banner 构造中断
                                            │
                              runOnUiThread{ 创建 AdView + addView } 永不执行
                                            │
                                   没有 AdView → 零网络请求 → 零回调

Godot 的 int 是 int64,装箱成 Long;而插件 aar 里的 Kotlin 是 this["ad_position"] as Int。
这个异常发生在 safeArea 属性初始化之后、runOnUiThread 之前,
所以 Banner 没被加进 banners 列表,后续 load_ad(uid) 取 banners[uid] 越界 —— 整条链静默。

注意:GitHub master 上的 DictionaryExtensions.kt 已经改成安全的
(this[key] as? Number)?.toInt(),但发布的 aar 4.0.1 仍是旧代码。
这就是为什么”看源码明明修过了,跑起来还是崩”。

修复代码

文件:addons/admob/src/api/AdView.gd

# Godot 的 JavaClass 构造器方法名 = 类的简单名
# (java_class_wrapper.cpp:1488: java_constructor_name = 类名最后一段)
# 所以是 Integer.Integer(v),走 env->NewObjectA(..., "<init>", ...),
# 返回 JavaObject;JavaObject 进 Dictionary 时引擎直接透传 jobject。
static var _integer_class = null
static var _integer_class_ready := false

static func _java_int(value: int):
    if not _integer_class_ready:
        _integer_class_ready = true
        if OS.get_name() == "Android":
            _integer_class = JavaClassWrapper.wrap("java.lang.Integer")
    if _integer_class == null:
        return value
    return _integer_class.Integer(value)

然后 _init() 里三个 int 全部走它:

var ad_view_dictionary := {
    "ad_unit_id": ad_unit_id,
    "ad_position": _java_int(ad_position),
    "ad_size": {
        "width": _java_int(ad_size.width),
        "height": _java_int(ad_size.height)
    }
}

为什么插页/奖励不受影响

它们的 AdRequest.convert_to_dictionary() 里只有 String 和空 Dictionary,
没有 int,碰不到这个 checkcast。这也解释了”为什么偏偏只有横幅坏”。


六、第 4 步:业务代码

6.1 官方测试广告单元 ID(任何包名下都能出测试广告)

const BANNER_ID       := "ca-app-pub-3940256099942544/6300978111"
const INTERSTITIAL_ID := "ca-app-pub-3940256099942544/1033173712"
const REWARDED_ID     := "ca-app-pub-3940256099942544/5224354917"

6.2 注册测试设备

真机上跑测试广告必须把设备注册成测试设备,否则拿不到测试广告。
设备哈希从 logcat 里 SDK 自己打印的那句取:

Use RequestConfiguration.Builder().setTestDeviceIds(Arrays.asList("599DD0E66111F3FAC24560DEABA32062"))
adb logcat -d | grep -i "setTestDeviceIds"

6.3 AdManager.gd(AutoLoad)

func _ready() -> void:
    if OS.get_name() != "Android" and OS.get_name() != "iOS":
        return

    # 1) 注册测试设备
    var cfg := RequestConfiguration.new()
    cfg.test_device_ids = [TEST_DEVICE_ID]
    MobileAds.set_request_configuration(cfg)

    # 2) 初始化(异步)
    var listener := OnInitializationCompleteListener.new()
    listener.on_initialization_complete = func(_status: InitializationStatus) -> void:
        _initialized = true
    MobileAds.initialize(listener)

横幅(贴在竖屏底部):

func load_banner() -> void:
    if _banner_view != null:
        _banner_view.destroy()
        _banner_view = null

    var ad_size := AdSize.get_current_orientation_anchored_adaptive_banner_ad_size(AdSize.FULL_WIDTH)
    var view := AdView.new(BANNER_ID, ad_size, AdPosition.Values.BOTTOM)

    view.ad_listener.on_ad_loaded = func() -> void:
        view.show()                      # 加载成功才 show
    view.ad_listener.on_ad_failed_to_load = func(err: LoadAdError) -> void:
        print("横幅失败 code=%d %s" % [err.code, err.message])

    _banner_view = view
    view.load_ad(AdRequest.new())

插页:

func load_interstitial() -> void:
    var cb := InterstitialAdLoadCallback.new()
    cb.on_ad_loaded = func(ad: InterstitialAd) -> void:
        _interstitial = ad
        ad.full_screen_content_callback.on_ad_dismissed_full_screen_content = func() -> void:
            _interstitial.destroy()
            _interstitial = null
            load_interstitial()          # 关闭后自动预加载下一则
    cb.on_ad_failed_to_load = func(err: LoadAdError) -> void:
        print("插页失败 code=%d %s" % [err.code, err.message])
    InterstitialAdLoader.new().load(INTERSTITIAL_ID, AdRequest.new(), cb)

奖励:

func show_rewarded() -> void:
    if _rewarded == null:
        load_rewarded()
        return
    var listener := OnUserEarnedRewardListener.new()
    listener.on_user_earned_reward = func(item: RewardedItem) -> void:
        print("发放奖励 type=%s amount=%d" % [item.type, item.amount])
    _rewarded.show(listener)

6.4 界面布局要点

横幅是 Android 原生 View,叠在 Godot 画面上方,会盖住底部内容。
所以 UI 底部要留白:

root.add_theme_constant_override("margin_bottom", 140)   # 给横幅留位

6.5 时序

MobileAds.initialize 是异步的,别在 _ready() 里立刻发请求:

await get_tree().create_timer(2.5).timeout
AdManager.load_interstitial()
AdManager.load_rewarded()
await get_tree().create_timer(3.0).timeout
AdManager.load_banner()

七、第 5 步:导出 APK

7.1 export_presets.cfg 最小配置

[preset.0]
name="portrait"
platform="Android"
export_path="./AdMobPortrait.apk"

[preset.0.options]
gradle_build/use_gradle_build=true
gradle_build/export_format=0
architectures/arm64-v8a=true
package/unique_name="com.example.admobportrait"
package/signed=true
version/code=1
version/name="1.0"
screen/immersive_mode=true

7.2 ★ 每次导出前先清 *.tmp

中断导出会留下
android/build/res/values-*/godot_project_name_string.xmlNNNN.tmp,
下次构建直接失败:

mergeStandardDebugResources FAILED - The file name must end with .xml
find android/build/res -name "*.tmp" -delete

7.3 导出命令

godot --headless --export-debug "portrait" "C:/path/to/AdMobPortrait.apk"

首次 Gradle 构建约 7~15 分钟(之后有缓存会快很多)。
APK 出现在 build/ 下时就已构建完成,Godot 进程可能还在做收尾。


八、第 6 步:装到手机

⚠ 坑:INSTALL_FAILED_USER_RESTRICTED: Install canceled by user

adb install -r -t app.apk
# → INSTALL_FAILED_USER_RESTRICTED: Install canceled by user

试过 keyevent 224 唤醒、wm dismiss-keyguard、pm install、文件管理器安装,都无效。
真正的原因是 AppOps 权限被关:

adb shell appops set com.android.shell REQUEST_INSTALL_PACKAGES allow
adb install -r -t app.apk
# → Success

在 Git Bash 下记得 export MSYS_NO_PATHCONV=1,否则 /data/local/tmp/...
这类路径会被自动转换。


九、第 7 步:验证广告真的出来了

9.1 抓日志

adb logcat -c                                        # 先清空
adb shell monkey -p com.example.admobportrait -c android.intent.category.LAUNCHER 1
sleep 14
adb logcat -d -v brief | grep -i "poing-godot-admob\|godot   "

9.2 健康日志长这样

[07:23:02] 正在初始化 MobileAds ...
[07:23:03] MobileAds 初始化完成 ✔
[07:23:06] 插页:已发起加载请求 ...
[07:23:06] 奖励:已发起加载请求 ...
[07:23:09] 横幅尺寸:411 x 64 dp
[07:23:09] 横幅:java.lang.Integer 装箱 ✔
D/poing-godot-admob: Safe Area of screen: Rect(0, 104 - 0, 0).
D/Ads: AdRequestServiceImpl: Sending request: .../mads/gma     ← 真的发出请求了
[07:23:11] 横幅:加载成功 ✔ → 自动展示
[07:23:11] 横幅:已展示(impression)
[07:23:11] 插页:加载成功 ✔ 可以展示了
[07:23:11] 奖励:加载成功 ✔ 可以展示了

9.3 自动点按钮

Godot 的按钮是自绘的,uiautomator 定位不到,只能用坐标点:

adb shell wm size            # 确认分辨率,例如 1080x2400
adb shell input tap 540 690  # 点"显示插页广告"

9.4 截图确认

adb exec-out screencap -p > screen.png

底部能看到 Google 测试横幅(「测试广告 / Nice job! This is a 320×50 test ad.」)即为成功。


十、排错速查表

现象 根因 解决
导出报 due to configuration errors: 消息为空 import_etc2_astc 未开 project.godot 加 textures/vram_compression/import_etc2_astc=true
脚本报 AdView / MobileAds not declared 全局类名缓存未生成 跑一次 godot --headless --editor --quit
No project icon specified 缺图标 加 config/icon="res://icon.svg"
mergeStandardDebugResources FAILED ... must end with .xml 上次导出中断留了 *.tmp find android/build/res -name "*.tmp" -delete
INSTALL_FAILED_USER_RESTRICTED AppOps 权限被关 adb shell appops set com.android.shell REQUEST_INSTALL_PACKAGES allow
广告请求 403 play-services-ads 版本停服 补丁 1,升到 Maven latest
Array[String] does not have the same element type Godot 4.6 typed-array bug 补丁 2,改 PackedStringArray
横幅无任何回调、SDK 不发请求 int 装箱成 Long,checkcast Integer 崩溃 补丁 3,_java_int()
插页/奖励一直”未准备好” 没等初始化完成就发请求 初始化回调后再等 2~3 秒
横幅盖住 UI 底部 原生 View 叠在画面上 UI 底部留 140px 空白

十一、换成自己的广告 ID 时要做什么

  1. AdMob 后台创建应用 + 三个广告单元,拿到真实 ID,替换 3 个常量。
  2. 把测试设备注册那段删掉或改成只在 debug 生效,否则线上用户拿不到真实广告。
  3. 改包名 package/unique_name 为你自己的域名反写。
  4. 发布前换成 release 导出(--export-release)并配置正式签名。
  5. 欧盟/英国用户需要接 UMP consent 流程(插件里 PoingGodotAdMobUserMessagingPlatform 已提供)。

附:本项目文件清单

AdMobPortrait/
├── project.godot              竖屏 + AutoLoad + import_etc2_astc
├── export_presets.cfg         preset 名 "portrait"
├── AdManager.gd               广告管理器(AutoLoad)
├── Main.gd / Main.tscn        竖屏演示 UI(纯代码搭建)
├── icon.svg
├── addons/admob/              插件(含 3 处补丁)
│   ├── src/api/AdView.gd                      ← 补丁 3
│   ├── src/api/core/AdRequest.gd              ← 补丁 2
│   ├── src/api/MobileAds.gd                   ← 补丁 2
│   ├── src/api/{Interstitial,Rewarded,RewardedInterstitial}AdLoader.gd  ← 补丁 2
│   └── android/bin/ads/poing_godot_admob_ads.gd   ← 补丁 1
├── android/                   Android 构建模板
└── build/
    ├── admob-portrait.apk     已验证可安装的 debug 包
    └── screen_*.png           真机验证截图