晴伴是个小 App:微信、QQ 的消息和手机电量,会变成屏幕上那只果冻宠物的表情和气泡。逻辑层是 uni-app(Vue 3 + Pinia),画面层是一套自绘的 WebGL1 引擎,跑在页面的 ArkWeb 里。
这只果冻最早不是我的。它来自 softie · 软乎乎(源码,MIT,© 2026 yuanyang749):一个原生 WebGPU + three.js 的网页减压玩具。第一次打开那个页面,按住能陷下去、拎起来能拉伸、松手会回弹,当时就一个念头——这东西不该只在浏览器标签页里,它应该长在手机桌面上。
于是有了晴伴:把"软乎乎"搬进 App,再让它替你看消息。搬的过程比想的重——原版是 WebGPU + three.js,而 ArkWeb 里没有 WebGPU,体积和后处理也压不住,只能自己写一套 WebGL1 渲染。
Android 上一切正常,搬到 HarmonyOS NEXT 的第一天,问题连着来。下面这五个都是真实卡住的地方,现象、定位、根因、改法都在。
技术栈
| 项 | 版本 / 选择 |
|---|---|
| 应用框架 | uni-app(Vue 3 + Pinia),打包 npx uni build -p app-harmony |
| 宿主 WebView | HarmonyOS NEXT 的 ArkWeb(DevEco 模拟器 127.0.0.1:5555 验证) |
| 画面 | 自绘 WebGL1(优先 WebGL2 上下文),GLSL ES 1.00 |
| 为什么不用 three.js / WebGPU | ArkWeb 没有 WebGPU;three.js 的体积和后处理在卡片里也压不住 |
| 其它 | Web Audio 现场合成音效,不加载音频文件 |
问题一:萨摩耶变成一块白方块
鸿蒙上宠物位置是一整块奶油色方块,宠物没出来(后面压着授权弹窗)
另一次:宠物出来了,但背后透出一块白色方块
问题:史莱姆(程序化画出来的)显示正常,换成萨摩耶(一张透明原画铺在同一个果冻体上)就成了整块发白,边缘细节全没了。第一反应是贴图没加载上,但请求是 200、texImage2D 也没报错,控制台安静得像没事——问题不在"有没有图",在"混出来是什么颜色"。
解决:三处一起改。
第一处,混合方程从预乘写法改成非预乘:
// 改之前:src 被满强度叠加上去
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA)
// 改之后:非预乘输出配 SRC_ALPHA
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
第二处,上传贴图时显式关掉预乘,避免各家实现对 canvas 源处理不一致:
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1)
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, 0)
第三处,让失败出声。原来贴图加载失败是个静默 catch,画面只剩背景色,控制台什么都没有;loadImage 也没有超时,候选链卡在 pending 时一张图都画不出来。改成:失败明确报错并退回静态表情;loadImage 承诺永不挂起(失败 resolve(null),8 秒超时)。
这条要说清楚边界:我是按提交记录和代码注释写的,手上没有鸿蒙设备,没在 ArkWeb 上复现过。本机用同一张原画做对照实验时,透明像素实测是 (0,0,0,0)、半透明像素只占 2.5% 左右,单靠混合方程凑不出"整块白"——真实触发条件可能还牵到贴图候选链(仓库里同名的 .jpg 版本没有 alpha 通道),这点我没有证据,不写结论。
问题二:ArkWeb 里 rAF 和 document.hidden 都不可信
宠物整个消失,只剩「嘿嘿~」气泡和芯片
问题:画好的宠物不动了。不是卡住那种不动,是画面停在第一帧,嘴型、高光都不再变。根因是 ArkWeb 里三条常规假设全部失效:
document.hidden经常一直是true,哪怕页面就在前台;- 页面在
hidden状态下,requestAnimationFrame可能不再回调; - 页面
onHide也不可靠。
解决:鸿蒙分支不再信它们,用定时器保底,并且不因为 hidden 停画:
function schedule() {
if (destroyed || raf || timer) return
// ArkWeb 在 document.hidden===true 时经常不再回调 rAF,改用定时器保底。
if (runtime.harmony) {
timer = setTimeout(() => { timer = 0; frame(now()) }, 16)
return
}
raf = requestAnimationFrame(frame)
}
function frame(now) {
// 鸿蒙:document.hidden / 页面 onHide 都不可靠,不能靠它们停画。
const pageHidden = !runtime.harmony && document.hidden
if (!paused && !pageHidden) { /* 推进物理、绘制 */ }
schedule()
}
代价是前台也不省电,但对一只卡片里的宠物来说,能看见比省电重要。
配套还有两条,缺一个都会变成"卡片是空的":
- 布局没出来时
getBoundingClientRect()先返回 0,canvas 会被收成 1×1 再也长不回来 → 启动时同步measure() + paint()画一帧,并且前 8 秒每 200ms 轮询一次宿主尺寸; ready只在真的画出来、宿主宽高 ≥ 8 之后才通知逻辑层。早发ready会把 fallback(静态表情)藏掉,用户看到的是一张空的奶油色卡片。
问题三:再套一层 web-view,卡片就空了
问题:想在页面里再套一个 <web-view> "隔离" WebGL,结果子 Web 不跟着卡片走,铺到了窗口原点(模拟器实测约 [0,117][648,676]),卡片里只剩空底,整页左上角多出一块 Web。
解决:不套娃。画面层改成在页面自己的 ArkWeb 里用 renderjs 建 canvas:
页面(uni-app / Vue)
└─ renderjs createPetEngine(host)
└─ <canvas> 画在宿主容器里
<web-view> 在模板里留着,但必须 v-if="useEmbed && alive",而 useEmbed 默认 false。/static/pet/embed.html 只给 H5 调试用(?pet=embed),鸿蒙路径永远不要打开它。
问题四:看得见,点不动
问题:果冻在屏幕上,点它没反应:不揉、不冒泡,像张图片。
解决:查下来是四个原因叠在一起,逐条对应改:
| 原因 | 改法 |
|---|---|
fallback 压在 canvas 上面,opacity: 0 仍然吃触摸 | .fallback.hidden 加 visibility: hidden; pointer-events: none |
ArkWeb 按下立刻触发 lostpointercapture | 不把这次事件当成松手 |
有 PointerEvent 构造器但不派发 | canvas 和宿主同时听 pointer* 与 touch*,50ms 内去重 |
| 命中椭球太严,点到卡片空白处没反应 | 射线打到身体平面后投影回表面,整张卡都能揉 |
去重那段:
// 若 pointer 已处理,50ms 内忽略 touch,避免双触发。
if (now() - lastPointerAt < 50) return
还有一条容易漏:监听要挂在宿主容器(.slime-host)上,不只挂 canvas——fallback 或合成层随时可能把 canvas 挡住。
问题五:悬浮窗一开就闪退
问题:Android 上"显示在其他应用上层"是原生悬浮窗,里面放一个 WebView 画宠物。这个做法搬到鸿蒙,一开就闪退。
解决:不在 ArkWeb 里硬撑,悬浮窗改成原生绘制,WebGL 只留在页面里的 ArkWeb。鸿蒙上的"挂在屏幕上"换了路径:不走系统浮窗,走 supportWindowMode 的 floating / split,从后台任务卡片点"小窗"收成约 360×560,同一个 ArkWeb 里的果冻还在。
这条只做到了规避——换成原生绘制之后不再闪退,但没拿到 ArkWeb 被复用到浮窗里的崩溃栈,所以不敢说根因就是它。
*华为手机中渲染的样子