在 Unity 里塞一个能跑前端页面的浏览器控件,第一次听像是炫技,做过一次基本就回不去了。我最早接这个需求是给一套线下展厅的 Windows 一体机做交互,右侧要挂一块活动公告区,运营每周换一次文案和配图。按 UGUI 那套做,每换一次就得重新打包、重新部署到十几台机器上,来回折腾两天。换成 Unity 内嵌前端网页与通信的方案之后,运营在后台改完页面推一次资源包,机器重启就生效,维护成本直接掉一个量级。
这里说的内嵌网页,不是把系统浏览器拉起来盖在应用上面,而是把 Chromium 或者系统 WebView 的渲染结果当成一张纹理喂给 Unity,你想贴到 UI 上就贴 UI,想贴到 3D 物体表面就贴物体表面。真正麻烦的部分从来不是"显示出来",而是 Unity 和网页之间怎么说话——页面里的按钮点了,Unity 得知道;Unity 里模型换了,页面上那组数据得跟着变。ZFBrowser(标题里写的 zfbrower 就是它,ZenFulcrum 出的那套 Embedded Browser,社区习惯叫 ZFBrowser)和 3D WebView 是这个需求下最常被拉出来对比的两套插件,两者在通信模型上的差异,直接决定了你后面的代码要写多脏。
这篇内容适合已经在做 Unity 客户端、又被前端需求缠上的同学,也适合刚从 Web 前端转过来、想在 3D 场景里复用自己那套 Vue/React 经验的人。我按自己踩坑的顺序,把两条路线的底层差异、通信通道怎么搭、一个能跑通的最小工程、以及打包之后才会暴露的那些问题,从头捋一遍。
1. 内嵌网页这件事,到底在解决什么问题
1.1 三类最典型的使用场景
第一类是运营内容型。公告、排行榜、活动页、兑换码入口,这类内容的特点是改得勤、逻辑简单、样式花哨。用 Unity 原生 UI 做,一个渐变阴影+异形卡片的视觉效果就够美术折腾半天,改一次还得走一遍打包发版流程。交给前端,一套 CSS 半小时搞完,还能做热更新。
第二类是表单与配置型。设备参数配置、账号绑定、数据筛选面板,这类东西 Web 生态太成熟了——表单校验、日期选择器、下拉搜索、表格分页,前端随手一个组件库就搞定,Unity 侧自己撸一套至少要耗掉一个人两周。
第三类是数据可视化型。ECharts、D3 这类库在浏览器里跑得稳稳当当,你硬要在 Unity 里画折线图饼图,还得自己写 Mesh 生成或者买图表插件,纯属给自己加戏。数字孪生类项目里,Unity 负责 3D 场景和模型状态,图表和指标面板交给网页,分工最舒服。
第四类比较特殊,是VR/MR 场景里的 2D 面板。PICO 4 这类一体机跑 Unity,你想在里面开个浏览器页面看操作手册、看直播流、看后台系统,就只能靠 Android 版的 3D WebView 那类方案。
1.2 为什么不用 Unity 原生 UI 硬做
很多人第一反应是"我 UGUI 也能做啊",能做,但要算清楚账。我把两种方案的差异列了个表,你可以对着自己项目的情况看:
| 维度 | Unity 原生 UI | 内嵌前端网页 |
|---|---|---|
| 迭代方式 | 改完必须重新打包发版 | 改完推资源,热更新生效 |
| 人力结构 | 需要懂 Unity 的 UI 开发 | 前端工程师就能维护 |
| 复杂排版 | 靠 Layout 组件硬撑,容易崩 | Flex/Grid 一次写好 |
| 富文本与图文混排 | TextMeshPro 勉强够用 | 原生 HTML 能力 |
| 图表可视化 | 需要额外插件或自绘 | 现成库直接引入 |
| 包体影响 | 几乎为零 | 内核体积几十到上百 MB |
| 启动开销 | 毫秒级 | 首次加载数百毫秒起 |
| 内存占用 | 可控 | 内核常驻,占用明显 |
| 调试体验 | Unity 内调试 | Chrome DevTools 级别 |
| 平台一致性 | 各平台差异小 | 各平台内核不同,行为有差异 |
判断标准其实就一条:这块内容的变更频率是不是高于客户端发版频率。如果是,网页方案划算;如果这块 UI 三年不动,那原生做更省事,别为了技术而技术。
2. ZFBrowser 和 3D WebView 的底层差异
2.1 ZFBrowser 的定位与它的历史包袱
ZFBrowser 走的是 Chromium Embedded Framework(CEF)路线,在桌面平台上跑一个独立的 Chromium 进程,把渲染结果通过共享内存交回 Unity,Unity 侧拿到一帧纹理。这个架构的好处是内核行为完全可控——你用的是自己带的那份 Chromium,跟用户机器上装的是 Chrome 还是 Edge、版本多少,一点关系都没有。前端同学写的页面在这台机器上跑成什么样,换一台机器还是一模一样。
代价也很明显:包体大。CEF 那套二进制文件,Windows 64 位下基本是 100MB 起步,压缩之后也在 50MB 以上,这些文件要放在 StreamingAssets 里跟着走。而且它只覆盖 Windows、macOS、Linux 三个桌面平台,移动端官方是不支持的。另外这套插件近几年的更新节奏偏慢,新版本 Unity 出来之后经常要等一阵子才能适配,我遇过 Unity 升级之后插件报 API 变更错误的坑,最后是回退 Unity 小版本等适配。
所以 ZFBrowser 更适合这么几种情况:纯 PC 桌面客户端、线下一体机、大屏展示、Windows 平板这类形态固定的场景,团队对包体不敏感,但对内核行为一致性要求高。
2.2 3D WebView 的版本矩阵
3D WebView(Vuplex 出的)走的是另一条路:一个平台一个包,各平台用各平台的原生内核。你买的是哪个平台的包,就只能打包哪个平台,价格也是分开算的。这一点很多人第一次接触会懵,以为买一次全平台通用。
| 平台包 | 底层内核 | 渲染形态 | 需要单独购买 |
|---|---|---|---|
| Windows / macOS | Chromium 独立进程 | 纹理 | 是 |
| Android | 系统 WebView,可选内核 | 纹理或原生叠加 | 是 |
| iOS | 系统 WebView | 纹理 | 是 |
| WebGL | 浏览器内 iframe | DOM 覆盖层 | 是 |
| UWP / HoloLens | 系统内核 | 纹理 | 是 |
这个矩阵带来两个直接影响。一是前端代码的兼容面变宽:Android 上用的是系统 WebView,各家 ROM 的 WebView 版本差异很大,老设备上可能是很旧的 Chromium 内核,你用了新的 CSS 特性在展厅那台老平板上直接白屏。二是调试成本上升:Windows 上跑得好好的页面,扔到 Android 上可能布局全乱,因为内核根本不是一个。
好处是包体小得多。Android 版因为用系统 WebView,增量可能只有几 MB,iOS 同理。如果你是移动端项目,包体是硬指标,那基本没有别的选择。
2.3 渲染接管方式:纹理模式与原生叠加
这一点很多人刚开始会混淆。以 Android 版 3D WebView 为例,它其实提供两种显示模式:
纹理模式是把网页渲染成一张 Texture2D,然后你可以把它贴到 UI 的 RawImage 上、贴到 3D 物体的材质上、贴到 RenderTexture 上做后处理。这种模式的自由度最高,VR 场景里必须用这种,因为你没法在立体渲染里塞一个平面视图。代价是每帧要走一次纹理上传,分辨率和帧率之间要权衡。
原生叠加模式是在 Android 的视图层级最上面盖一层原生 WebView,Unity 的渲染在下面。这种模式性能好、清晰度高、滚动跟手,但它永远在最上层,你没法在它前面放 Unity 的 3D 物体遮挡,也没法用在 VR 里。适合那种后台管理面板、设置页这类"整屏就是网页"的场景。
我个人的选择逻辑很简单:只要涉及 3D 遮挡关系或者 VR,就用纹理模式;整屏纯网页的配置页,就用原生叠加。别想着用纹理模式硬扛复杂长列表,滚动性能会教你做人。
3. Unity 与网页的双向通信怎么搭
这是整件事的核心,也是坑最多的地方。
3.1 从 C# 调用网页
两套插件在这一步的 API 思路差不多,都是"执行一段 JS"或者"调用页面上的某个全局函数"。
执行脚本是最直接的方式。3D WebView 是:
webViewPrefab.WebView.ExecuteJavaScript( "document.getElementById('title').innerText = '设备已连接';" );ZFBrowser 类似,也是往页面里塞一段脚本执行。
这种方式能用,但我不建议大量使用。原因有三个。第一,字符串拼接极其容易出错,参数里带个单引号、带个换行、带个中文标点,脚本就静默失败了,而且没有明显报错,你只能对着屏幕发呆。第二,没法拿到返回值(多数实现是异步的,等你拿到结果时业务逻辑早走完了)。第三,逻辑散落在 C# 里,前端同学改个 DOM 结构,你这边所有拼接字符串的地方全废。
更推荐的做法是在网页侧预留接口函数,C# 只负责调用:
页面里先写好:
window.GameBridge = { setDeviceState: function (deviceId, state) { document.querySelector('[data-device="' + deviceId + '"]') .classList.toggle('online', state === 1); }, setTheme: function (color) { document.documentElement.style.setProperty('--primary', color); } };C# 侧只传函数名和参数。这样 DOM 结构怎么变,改的都是网页那一层,Unity 侧代码不用动。这是我在第三个项目才想明白的事,前两个项目的 C# 里全是一坨拼接字符串,改一次页面要重新过一遍客户端代码,非常痛苦。
3.2 从网页回调 Unity
3D WebView 用的是单向 postMessage 通道模型。网页侧通过window.vuplex.postMessage()往外发,C# 侧监听MessageEmitted事件接收:
function notifyUnity(type, payload) { if (!window.vuplex) return; window.vuplex.postMessage(JSON.stringify({ type: type, payload: payload })); }webViewPrefab.WebView.MessageEmitted += (sender, eventArgs) => { var raw = eventArgs.Value; // raw 就是网页侧 postMessage 出来的字符串 };ZFBrowser 走的是注册函数模型。C# 侧先注册:
browser.RegisterFunction("onPageEvent", (args) => { string json = args[0]; // 具体签名以插件对应版本文档为准 HandlePageEvent(json); });网页侧调用:
window.Unity.call(JSON.stringify({ type: 'select', id: 'robot_01' }));两种模型对比下来,postMessage 那条通用出口更容易做统一治理。因为它只有一个入口,你可以在 C# 侧写一个分发器,所有消息先过一遍日志、先做一次格式校验,再按type字段路由到不同处理函数。而注册函数模型是多个入口,每个函数体里都得自己处理参数解析和异常,写多了就散。
3.3 消息格式设计:别省这一步
我的建议是从第一天就定死一个信封格式,所有跨端消息都套进去:
{ "type": "device.select", "seq": 1024, "ts": 1700000000000, "payload": { "id": "robot_01" } }四个字段各有用途。type用命名空间式的点号分隔,方便做前缀路由和批量日志过滤。seq是自增序号,用于请求-响应配对——网页发一个请求,Unity 处理完带着同一个seq回消息,网页侧才能知道这次响应对应哪次请求。ts是时间戳,排查时序问题时能直接看出来是先点的按钮还是先加载完页面。payload放业务数据,结构随意。
没有seq的通信,一旦出现两条并发请求,你就分不清哪个响应属于谁了。这种事在"点击列表项加载详情"的场景下一定会遇上,用户手快连点两次,页面显示了第二次的标题配第一次的内容。
3.4 参数类型的那些坑
JS 里的数字全都是双精度浮点数,没有 int。你从网页传一个1过来,C# 侧反序列化成double,如果你直接强转int遇到1.0000001就炸。所以 JSON 反序列化时统一用double接,业务层再转。
字符串转义是另一个高频坑。参数里带引号、带反斜杠、带中文引号,拼接脚本必然出问题。只要涉及传参,一律走 JSON 序列化,不要手动拼字符串。
还有一个容易忽略的是空值和 undefined。网页侧变量没赋值,JSON.stringify之后那个字段直接消失,C# 侧反序列化拿到的是null,如果你的业务代码没做判空,就是一场空引用崩溃。
3.5 线程与频率
两套插件的回调基本都在主线程,所以你在回调里直接碰 Unity 对象是安全的。但要注意不要在回调里做重活。网页侧如果有个滚动列表,每次滚动都发一条消息告诉 Unity 当前可见项,一秒几十条,主线程直接卡死。
处理方法有几种。一是节流,网页侧用一个定时器,把 100ms 内的多条消息合并成一条再发。二是批量打包,一次发一个数组而不是发一百条消息。三是降低精度,滚动位置只需要整数百分比,不需要传浮点坐标。
我在一个数字孪生项目里踩过这个坑:网页侧图表 hover 时实时把鼠标位置传给 Unity 做高亮联动,鼠标一动就是几百条消息,帧率从 60 掉到 20。后来改成 80ms 节流加坐标取整,帧率回来了,视觉上完全看不出差别。
4. 一个能跑通的最小工程
4.1 环境准备与工程结构
Unity 版本我建议选 2021.3 LTS 或 2022.3 LTS。这两个版本插件生态覆盖最全,社区里遇到问题也最容易搜到答案。别一上来就用最新的 Tech 版本,插件适配往往滞后半年。
工程目录我习惯这么组织:
Assets/ Plugins/ # 插件本体 Scripts/ WebBridge/ # 桥接层,只负责收发和路由 WebHandlers/ # 业务处理,一个 type 一个处理类 UI/ # Unity 侧 UI StreamingAssets/ WebApp/ # 前端构建产物 index.html assets/把前端构建产物放在StreamingAssets/WebApp下面,好处是打包时自动跟着走。前端同学本地npm run build之后把dist目录拷过来覆盖,Unity 侧重新出包就行,不需要额外配置。
有一点要提前说清楚:不同平台对本地文件的访问限制不一样。桌面平台上用file://协议直接读 StreamingAssets 通常可以,但 Android 和 iOS 上出于安全策略,直接读本地文件的限制更多,实践中更稳的做法是走本地 HTTP 服务,或者直接把 HTML 内容读成字符串注入。这块具体用哪种,要按你选的插件版本文档来,别想当然。
4.2 网页侧的准备工作
前端这边要多写一个适配层,把"在浏览器里跑"和"在 Unity 里跑"两种环境统一掉:
(function () { const inUnity = typeof window.vuplex !== 'undefined'; const messageHandlers = {}; // 接收 Unity 发来的消息 if (inUnity) { window.vuplex.addEventListener('message', function (event) { let msg; try { msg = JSON.parse(event.data); } catch (e) { console.warn('[bridge] 消息解析失败', event.data); return; } const handler = messageHandlers[msg.type]; if (handler) { handler(msg.payload, msg.seq); } else { console.warn('[bridge] 未注册的消息类型', msg.type); } }); } window.Bridge = { on: function (type, fn) { messageHandlers[type] = fn; }, send: function (type, payload, seq) { const envelope = { type: type, seq: seq || 0, ts: Date.now(), payload: payload || {} }; const text = JSON.stringify(envelope); if (inUnity) { window.vuplex.postMessage(text); } else { console.log('[bridge][mock]', text); } } }; })();关键点是那个inUnity判断和mock分支。有了它,前端同学在自己的 Chrome 里就能把整个页面调完,不用每次都打包 Unity。这个改动看着小,但它把前端和 Unity 的开发解耦了,效率差距是数量级的。
4.3 C# 侧的桥接脚本
C# 这边我也分成两层:桥接层负责收发包,业务层负责处理。
using System; using System.Collections.Generic; using UnityEngine; using Vuplex.WebView; [Serializable] public class Envelope { public string type; public int seq; public long ts; public string payload; // 先按字符串接,业务层再解析 } public class WebBridge : MonoBehaviour { public CanvasWebViewPrefab webViewPrefab; public string localPagePath = "WebApp/index.html"; private readonly Dictionary<string, Action<string, int>> _handlers = new Dictionary<string, Action<string, int>>(); async void Start() { // 等插件初始化完成,没初始化就调 API 会直接失效 await webViewPrefab.WaitUntilInitialized(); webViewPrefab.WebView.MessageEmitted += OnMessageEmitted; webViewPrefab.WebView.PageLoadFailed += OnPageLoadFailed; webViewPrefab.WebView.LoadProgressChanged += (s, e) => { if (e.Progress == 1f) Debug.Log("[web] 页面加载完成"); }; RegisterHandlers(); LoadLocalPage(); } void OnDestroy() { if (webViewPrefab != null && webViewPrefab.WebView != null) { webViewPrefab.WebView.MessageEmitted -= OnMessageEmitted; webViewPrefab.WebView.PageLoadFailed -= OnPageLoadFailed; } } private void RegisterHandlers() { On("device.select", (payload, seq) => { var data = JsonUtility.FromJson<SelectPayload>(payload); SceneController.Instance.FocusDevice(data.id); Send("device.selected", new { id = data.id }, seq); }); On("ui.ready", (payload, seq) => { Debug.Log("[web] 页面就绪,开始推初始数据"); Send("device.list", DeviceRepo.All()); }); } private void OnMessageEmitted(object sender, EventArgs<string> e) { Envelope env; try { env = JsonUtility.FromJson<Envelope>(e.Value); } catch (Exception ex) { Debug.LogError($"[web] 信封解析失败: {ex.Message} / {e.Value}"); return; } if (env == null || string.IsNullOrEmpty(env.type)) { Debug.LogError($"[web] 非法消息: {e.Value}"); return; } if (_handlers.TryGetValue(env.type, out var handler)) { handler(env.payload, env.seq); } else { Debug.LogWarning($"[web] 未注册的类型: {env.type}"); } } private void OnPageLoadFailed(object sender, EventArgs<string> e) { Debug.LogError($"[web] 页面加载失败: {e.Value}"); } public void On(string type, Action<string, int> handler) { _handlers[type] = handler; } public void Send(string type, object payload, int seq = 0) { var env = new Envelope { type = type, seq = seq, ts = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), payload = payload == null ? "{}" : JsonUtility.ToJson(payload) }; var text = JsonUtility.ToJson(env); // 注意转义,直接拼字符串会炸 var escaped = text.Replace("\\", "\\\\").Replace("'", "\\'"); webViewPrefab.WebView.PostMessage(text); // 或者用 ExecuteJavaScript 分发到页面的 Bridge 上 } private void LoadLocalPage() { var fullPath = System.IO.Path.Combine( Application.streamingAssetsPath, localPagePath); // 具体加载方式按平台和插件版本选择,file:// 在移动端限制较多 webViewPrefab.WebView.LoadUrl("file://" + fullPath); } }这段代码里有几个点值得单独说。
WaitUntilInitialized是必须的。插件初始化是异步的,你在Start里直接调LoadUrl有一定概率不生效,表现为白屏而且没有报错。我第一个项目就吃了这个亏,排查了大半天。
PageLoadFailed一定要挂。默认情况下加载失败是静默的,你只看到一片白,不知道是路径错了、文件没打进包、还是内核崩了。挂上这个事件,至少能看到错误信息。
MessageEmitted一定要在OnDestroy里注销。这个组件如果被销毁重建(比如场景切换),不注销会积累监听,导致同一条消息被处理多次。这个 bug 特别隐蔽,表现是"点了删除按钮,一次删了两个"。
4.4 ZFBrowser 侧的对应写法
如果你用的是 ZFBrowser,桥接层结构类似,只是收发 API 换成它那套。C# 侧注册函数,网页侧通过window.Unity.call()往外发,加载本地页面用LoadURL("file://" + ...)。
区别在于 ZFBrowser 的注册函数是多入口的,我建议还是收敛成单入口——只注册一个总入口函数,把业务类型塞进 JSON 里,在 C# 侧再分发。这样两套插件的业务代码几乎可以复用,将来换插件只改桥接层那几十行。
5. 打包之后才会暴露的那些问题
5.1 常见问题速查
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 白屏,无任何报错 | 未等初始化就调用 API | 确认走了 WaitUntilInitialized |
| 白屏,有加载失败日志 | 路径错误或文件未打进包 | 检查打包后 StreamingAssets 目录内容 |
| 桌面正常,移动端白屏 | 平台本地文件访问限制 | 改走本地服务或内容注入 |
| 页面显示但样式全乱 | 移动端内核版本旧 | 降级 CSS 特性,加前缀 |
| 点击无反应 | 输入事件未转发 | 检查输入组件是否挂上 |
| 消息发出去没响应 | 页面未加载完就发送 | 加 ui.ready 握手协议 |
| 图片不显示 | 相对路径在打包后失效 | 统一用相对根目录的路径 |
| 内存持续上涨 | 页面反复加载未释放 | 复用同一个 WebView 实例 |
| 打包后脚本报错 | IL2CPP 裁剪掉了反射用的类 | 加 link.xml 或改用手写解析 |
| 帧率骤降 | 纹理分辨率过高 | 降分辨率,做节流 |
5.2 IL2CPP 裁剪这个坑
这个坑我单独拎出来讲,因为它特别难查。Mono 后端下一切正常,切到 IL2CPP 之后,反序列化突然报字段为空的错误。原因是 IL2CPP 在构建时会做代码裁剪,你那些只通过反射访问、代码里没有直接引用的字段和类,有可能被裁掉。
解决办法是加一个link.xml把涉及序列化的程序集和类型全部保留:
<linker> <assembly fullname="Assembly-CSharp"> <type fullname="Envelope" preserve="all" /> <type fullname="SelectPayload" preserve="all" /> </assembly> </linker>或者干脆放弃JsonUtility,换成 Newtonsoft.Json,它对裁剪的容忍度高一些,代价是包体和性能上略微吃亏。我的做法是:结构简单的用JsonUtility并配link.xml,结构复杂(需要字典、嵌套数组)的用 Newtonsoft。因为JsonUtility不支持字典,也不支持顶层数组,这两个限制在实际项目里很快就撞上了。
5.3 输入事件转发
纹理模式下,网页的点击是收不到系统输入的。你需要把 Unity 侧的鼠标或射线事件转发给浏览器。3D WebView 提供了相应的输入组件,挂上去之后,鼠标点击位置会转换成页面坐标。
这里有个坑:转发的事件和 Unity 自己的 UI 事件会打架。如果你的 WebView 上面还压着一层 Unity 的 UGUI 按钮,点击会同时触发两边。处理方式是控制 Canvas 的GraphicRaycaster顺序,或者在转发前判断点击位置是否落在 WebView 区域内。
移动端的触摸滚动是另一个问题。纹理模式下,手指拖动要转换成页面的滚动事件,跟手度通常不如原生 WebView,这是纹理方案的固有代价,只能靠提高渲染帧率缓解,没法根治。如果你的场景是长列表浏览为主,认真考虑一下原生叠加模式,别硬扛。
5.4 页面加载的生命周期管理
页面的加载和销毁要有明确的节奏,不然内存会一路往上走。我的做法是:
应用启动时创建一个 WebView 实例,全程复用,不销毁不重建。切换页面内容用LoadUrl或者直接调用页面的路由函数,而不是销毁重开。只有在确实要彻底重置(比如用户切换账号)的时候,才走一次完整重载。
页面不可见的时候(比如切到了别的模块),调用一次精简的暂停逻辑,或者至少停止向它推数据。一个后台不可见的页面每帧还在收消息在做 DOM 更新,是纯粹的浪费。这个优化我在一个项目里做了之后,一体机上的平均帧率从 45 提到了 58,什么都没改,就是把不可见页面的数据推送停了。
6. 选型和长期维护上的一些实话
6.1 到底选哪个
我把判断条件收敛成三条:
看平台。如果目标平台只有 Windows 桌面,两个都能用,看预算和团队熟悉度。如果涉及 Android、iOS、WebGL、VR 一体机,那基本只有 3D WebView 这条路,ZFBrowser 覆盖不到。
看包体预算。移动端或者对下载体积敏感的发行项目,几十到上百 MB 的内核增量是致命的,优先选系统内核方案。纯内网部署的一体机、PC 客户端,包体不敏感,CEF 的稳定性优势就体现出来了。
看内核一致性要求。如果你的页面用了比较新的前端技术栈,或者对字体渲染、动画精度有强要求,跨设备一致性很重要,那自带内核的 CEF 方案更稳。
6.2 一个容易被忽略的长期风险
这类插件有个共同特点:它们都依赖一个外部内核,而内核版本的更新节奏跟 Unity、跟你的项目节奏不在一条线上。你今天用着没问题,两年后 Unity 升到新的大版本,插件没跟上适配,你只有两条路——要么项目卡在旧版本 Unity,要么花时间迁移到别的方案。
应对办法是在架构上留一手:把桥接层封成一个接口,业务代码只依赖这个接口,不直接依赖插件的 API。我现在的项目里,业务层调的都是IWebViewService.Send()和IWebViewService.On(),底下具体是哪个插件实现,业务代码完全不知道。将来换插件,改的是那一个实现类,业务代码一行不动。
这个封装的成本大概是半天,收益是将来某一天你不会被迫做一次全项目重构。我吃过这个亏之后,现在每个项目第一件事就是写这层壳。
6.3 打包前的最后一轮检查
我在实际项目里攒了一份自检清单,出包前对着走一遍,能省掉不少返工:
先把构建好的前端产物完整覆盖到 StreamingAssets 目录,确认index.html在根位置而不是被套了一层 dist 文件夹——这个错误我犯过不止一次,前端同学给的压缩包解开之后多一层目录,Unity 加载路径就全部错位。然后打开工程里的 link.xml 看一眼,新加的序列化类有没有漏掉。接着在真机上跑一遍完整的通信链路,从页面点击到 Unity 响应再到页面更新,走通至少三个来回。最后断开网络跑一次,确认页面在离线状态下也能正常显示——本地资源路径如果写成了外部地址,联网时看不出来,断网就全白。
6.4 我个人踩过的最深的一个坑
最后说一个我自己印象最深的。有个项目在开发机上一切正常,出包给客户部署之后,页面偶尔会变成一片空白,重启又好了,概率大概十次里有一次。查了三天。
最后定位到是页面加载和消息推送的时序问题。Unity 侧有个定时器,每两秒推一次设备状态。在开发机上,机器快,页面加载 300ms 就完成了,两秒定时器第一次触发时页面早就在了。客户那台机器慢,页面加载要四五秒,第一次推送发出去的时候页面的监听还没注册,消息直接丢了。而页面又依赖这条初始数据来渲染列表,收不到就一直空着。
修法很简单,加一个握手协议:页面加载完成后主动发一条ui.ready,Unity 收到之后才开始推数据,并且维护一个"未就绪消息队列",就绪后补发。
这件事给我最大的教训是:跨进程通信里,永远不要假设对面的时序。任何依赖"我发的时候对方一定在听"的设计,都迟早会在某台慢机器上翻车。握手加队列,多写二十行代码,能省掉三天排查。