diff --git a/docs/content/zh-Hans/blog/DevLog-2026.03.29/assets/airi-pocket-android-godot-vrm-bg.mp4 b/docs/content/zh-Hans/blog/DevLog-2026.03.29/assets/airi-pocket-android-godot-vrm-bg.mp4 new file mode 100644 index 000000000..f729d8cce Binary files /dev/null and b/docs/content/zh-Hans/blog/DevLog-2026.03.29/assets/airi-pocket-android-godot-vrm-bg.mp4 differ diff --git a/docs/content/zh-Hans/blog/DevLog-2026.03.29/index.md b/docs/content/zh-Hans/blog/DevLog-2026.03.29/index.md new file mode 100644 index 000000000..075667dc1 --- /dev/null +++ b/docs/content/zh-Hans/blog/DevLog-2026.03.29/index.md @@ -0,0 +1,157 @@ +--- +title: DevLog @ 2026.03.29 +category: DevLog +date: 2026-03-29 +excerpt: | + 我们应该如何在 Godot 中嵌入 WebView?关于 AIRI 改用 Godot 引擎进行场景渲染但是依然用 Web 技术来实现 UI 和逻辑。 +--- + +欢迎回来,这里是 [@LemonNekoGH](https://github.com/LemonNekoGH),AIRI 维护者之一,距离柠猫的上一篇 DevLog 已经过去一个月了,这段时间里又整了一点点小活,今天就来和大家分享一下。 + +我们在两个月前引入了 Capacitor 来构建基于 WebView 的原生移动端应用,以便利用移动端设备的一些特有功能,比如后台任务驻留、闹钟、日历、计步器什么的。 + +但是我们发现 Live2D 和 VRM 在 WebGL 上的性能表现不是特别好,而且内存占用偏高,加载一个 VRM 模型就会占用 700+ MB 的内存,这导致在部分设备上会直接崩溃,体验过于糟糕了。 + +于是我们开始寻找能渲染复杂 3D 场景的替代方案,柠猫负责的部分是调研 Godot 引擎,然而 Godot 的 UI 开发体验过于差了,很难做到现在 Web 页面这样的复杂度,而且几乎所有的 UI 都要重写,所以我在尝试在 Godot 画面前叠加 WebView 的方法,这样我们依然可以继续用现有的 UI 框架。 + +但是,到底该怎么做呢? + +## Android 端 + +我找了一圈发现并没有什么比较合适的库所以我基于自己贫瘠的 Android 开发知识,拿到 Godot 所在的 Activity 的根 View,直接往根 View 顶上叠一个原生 WebView。 + +所幸,我通过 `adb shell uiautomator dump` 命令拿到了 Godot 所在的 Activity 的根 View 的 XML 结构,知道了根 View 是 FrameLayout,这样没有什么很复杂的布局代码要写了。 + +1. 首先我们要在 Godot 项目中启用 Android Gradle 导出,这样 Godot 就会帮我们创建一个 Gradle 工程,然后我们就可以在里面自定义 Android 部分的代码了。 +2. 通过搜索功能找到 `GodotApp.java` 这个文件,它是 Godot 的入口类,我们在这个类中可以拿到 Godot 所在的 Activity 的根 View。 + ```java + public class GodotApp extends Application { + // ...other code... + private final Runnable createWebView = () -> { + var rootView = (FrameLayout) this.findViewById(android.R.id.content).getRootView(); + Log.d("createWebView", rootView.getClass().getName()); + }; + + @Override + public void onGodotMainLoopStarted() { + super.onGodotMainLoopStarted(); + + runOnUiThread(createWebView); + } + // ...other code... + } + ``` + 由于 View 的添加和移除需要运行在主线程,所以我们需要使用 `runOnUiThread` 方法来确保在主线程执行。 +3. 创建一个 WebView 实例,并设置相关参数,然后加载 URL。 + ```java + var webview = new WebView(this); + webview.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + webview.getSettings().setJavaScriptEnabled(true); + webview.getSettings().setDomStorageEnabled(true); + webview.setWebContentsDebuggingEnabled(true); + + // 对 AIRI 来说很重要,因为我们需要透过 UI 来看 Godot 的场景 + webview.setBackgroundColor(Color.TRANSPARENT); + + webview.loadUrl("https://lemonbookpro.local:5273/"); + rootView.addView(webview); + ``` + +如果它按预期运行,应该是能看到这样的效果的。 + + + +按官方推荐的做法,其实我们应该写一个 Godot Android 插件的,但是柠猫这次为了快速验证这个想法,所以直接在 `GodotApp.java` 中写了。 + +然而,iOS 侧就没那么幸运了,只能写插件了。 + +## iOS 端 + +iOS 侧在创建好插件之后,我们并不能在里面找到 AppDelegate 相关的代码,只能在插件配置文件里定义插件入口了: + +```gdip +[config] +name="GodotWebView" +binary="GodotWebView.xcframework" +initialization="init_godot_webview" +deinitialization="deinit_godot_webview" +``` + +这里的 `initialization` 和 `deinitialization` 是插件的初始化和销毁回调,需要在 Objective-C 中实现,所以无论如何我们也需要这么一点点桥来把 Swift 和 Objective-C 联系起来。 + +```objc +#import + +extern "C" void godot_webview_swift_init(void); +extern "C" void godot_webview_swift_deinit(void); + +void init_godot_webview() { + godot_webview_swift_init(); +} + +void deinit_godot_webview() { + godot_webview_swift_deinit(); +} +``` + +类似的,在 iOS 上也需要找到主窗口的根视图: + +```swift +private func resolveHostWindow() -> UIWindow? { + let activeScenes = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .filter { scene in + scene.activationState == .foregroundActive || scene.activationState == .foregroundInactive + } + + logInfo("Resolving host window; activeSceneCount=\(activeScenes.count)") + for scene in activeScenes { + let windows = scene.windows + logInfo( + "Inspecting scene \(describe(scene: scene)); windowCount=\(windows.count); windows=\(windows.map { describe(view: $0) }.joined(separator: ", "))" + ) + if let keyWindow = windows.first(where: \.isKeyWindow) { + logInfo("Selected key window \(describe(view: keyWindow))") + return keyWindow + } + + if let firstWindow = windows.first { + logInfo("Selected first window \(describe(view: firstWindow))") + return firstWindow + } + } + + logError("No eligible foreground scene/window found") + return nil +} +``` + +创建 WebView 实例的方法也是类似的: + +```swift + let webViewConfiguration = WKWebViewConfiguration() + webViewConfiguration.allowsInlineMediaPlayback = true + webViewConfiguration.defaultWebpagePreferences.allowsContentJavaScript = true + + let webView = WKWebView(frame: .zero, configuration: webViewConfiguration) + webView.translatesAutoresizingMaskIntoConstraints = false + webView.navigationDelegate = self + webView.isOpaque = false + webView.backgroundColor = .clear + webView.scrollView.backgroundColor = .clear + webView.scrollView.contentInsetAdjustmentBehavior = .never + webView.accessibilityIdentifier = "GodotWebView" + + containerView.addSubview(webView) + pinToEdges(webView, in: containerView) +``` + +AI 教了我一种用 yml 来描述项目的工具 [xcodegen](https://github.com/yonaskolb/xcodegen) 这样我们可以不在项目里存一大堆 Xcode 工程文件了,但是我还没找到什么可以像 Android 那样拿到视图树的方法。 + +## 没写完的东西 + +到目前为止,这还只是成功叠上了 WebView,我们还没有实现 Godot 和 WebView 的通信,比如点击事件、键盘输入、触摸事件等等。 + +还有 Live2D、VRM 模型的渲染,我已经把它们放进去了,也进行了 Profile,但是这是下一篇 DevLog 的内容了。 + +今天的内容就到这里,感谢看到这里。