HarmonyOS 鸿蒙 BookFlip 翻书引擎实战 —— 从 drawPixelMapMesh 静默失效到可展弯曲几何
一、目标:不要假立体
先明确「真翻书」和「假立体」的区别。
最常见的偷懒做法是:左右两个半页容器,翻页时对右半页做 .rotate({ y: 1, angle: 180 })。它的问题:
-
纸是硬的。真纸翻起来中段会鼓起、两端放平,存在柔性卷曲;
-
透视是假的。rotate 旋转的是矩形,没有「近大远小」的投影;
-
背面内容处理粗糙。旋转 90° 之后镜像关系很容易搞反,文字反着印。
E019 的研究问题是:
能否在 ArkUI 上把「页内容栅格化 → mesh 顶点弯曲 → 纹理映射上屏」这条 Flutter
book_page_flip成熟路径完整复刻,并在真机上流畅拖动?
参考系是 Flutter 的 book_page_flip 0.1.0(MIT)。移植原则沿用本系列一贯的做法:保留行为契约与数学结构,替换平台基础设施;设备观感优先于源码同构。
另外预先声明一个对照实验:E021「刚性翻页」是 E019 的严格子集——同样的状态机、同样的渲染路径,只是把弯曲幅度设为零,页变成硬板。两套几何对应两种产品隐喻(杂志/纸感 vs 硬板书/卡牌),最后会给出选型分界。
二、能力闸门:先验证「画不画得出来」,再写组件
实验采用 P0 能力闸门 的推进方式:先写一个最小 probe,证明本机能画「可拖的弯曲 PixelMap」,不过闸门不立项写组件。这一步后来被证明价值极大——因为第一颗雷就在这里。
2.1 绘制入口:DrawModifier
ArkUI 侧的挂接点是 DrawModifier,在 drawContent 里拿 DrawContext.canvas(drawing.Canvas,来自 @kit.ArkGraphics2D):
class BookFlipProbeModifier extends DrawModifier {
drawContent(drawContext: DrawContext): void {
const canvas: drawing.Canvas = drawContext.canvas;
const size = drawContext.sizeInPixel; // ← 注意:px
// ...
}
}
组件上通过 .drawModifier(this.modifier) 挂载。重绘由 modifier.invalidate() 触发。
2.2 第一颗雷:px,不是 vp
drawing.Canvas 的坐标系是物理像素 px。probe 第一版按 vp(视觉像素)计算顶点,真机上整幅画面缩到左上角一个小角里——因为真机 px 密度是 vp 的 3 倍左右,vp 数值在 px 坐标系里只覆盖了左上角一小块。
正确做法是所有几何一律以 drawContext.sizeInPixel 为基准:
const w: number = size.width; // px
const h: number = size.height;
const layout: BookFlipLeafLayout = bookFlipFitOpenBook(w, h, pageAspect);
2.3 第二颗雷:drawPixelMapMesh 静默失效
SDK 的 drawing.Canvas 里有现成的 drawPixelMapMesh,签名正好是「PixelMap + 网格顶点」,看起来就是为翻页量身定做的。P0 第一版就走它。
真机结果:调用成功、不抛错、什么都不画。切到「Mesh only」模式整屏全黑;切「Flat only」图片正常——说明纹理、挂接、坐标都没问题,就是这条 API 不出画面。
这是最难排查的一类失败:没有异常、没有日志,只有黑屏。结论只能以本机现象记录(API 23 真机):
|
API |
真机结果 |
|---|---|
|
|
✅ 平铺/双页正常 |
|
|
❌ 不抛错、不绘制(Mesh only 全黑) |
|
|
✅ 主路径,卷曲可见 |
所以主路径锁定为:drawVertices 三角形网格 + ImageShader 纹理采样。它比 mesh 专用 API 多写一点顶点编排,但真机可靠,而且控制力更强(后面做深度排序、正反面 UV 都靠它)。
2.4 第三颗雷:BGRA 色序
Lab 生成的测试页是手写像素缓冲的 PixelMap。第一版按 RGBA 写,真机上页面中心色红蓝对调(蓝底变橙底)。原因是该路径的缓冲实际是 BGRA_8888 通道序。修正后 atlas 也统一用 BGRA 创建:
const atlas: image.PixelMap = image.createPixelMapSync({
size: { width: tw * 2, height: th },
pixelFormat: image.PixelMapFormat.BGRA_8888,
editable: true,
alphaType: image.AlphaType.OPAQUE
});
2.5 第四颗雷:异步 PixelMap 不重绘
PixelMap 解码/生成是异步的。异步就绪后画面不会自动刷新,需要主动 invalidate();如果绑定的是 native 资源,还要考虑 epoch 重建(这一坑在 E018 贴纸实验里有完整展开,见《HarmonyOS-Subject-Sticker》一文)。本实验的组件层为此在挂载后按 0/16/32/64/120ms 补发一轮重绘,兜住「首帧画在 0×0 尺寸上」的时序问题:
private scheduleRepaints(): void {
const delays: Array<number> = [0, 16, 32, 64, 120];
for (let i = 0; i < delays.length; i++) {
setTimeout((): void => { this.invalidate(); }, delays[i]);
}
}
三、几何模型:两阶段可展弯曲
过了闸门,接下来是这条路的核心:页的数学。全部实现在 BookFlipMeshCore.ets,零 ArkUI 依赖、纯函数、可单测。
3.1 为什么是「可展曲面」
纸的物理特性是不可拉伸——弯曲时沿纸面的弧长守恒。数学上这就是「可展曲面」(developable surface):圆柱面是它最简单的近似。所以页的弯曲用一段圆柱弧来建模,而不是随便一条样条。
模型分两个阶段(与 Flutter book_page_flip 的 BookFlipMesh._world 对齐):
阶段 1(局部弯曲):在页自身的 (bx, bz) 平面里做圆柱弯曲
弯曲幅度 a = amax · sin²(πt) ← 中段最弯,两端放平
阶段 2(整体旋转):绕竖直书脊旋转 φ
φ = π · smoothstep(t) + tilt · (v − grabV)
← 页从右半册荡到左半册;tilt 让捏住的位置领先翘起
3.2 弧长守恒的圆柱弯曲
关键代码(BookFlipMeshCore.ets):
// Developable bend: arc length u·pageW preserved.
let bx: number;
let bz: number;
if (a < 1e-4 || pageW <= 1) {
bx = u * pageW; // 平板退化
bz = 0;
} else {
const r: number = pageW / a; // 圆柱半径
bx = r * Math.sin(a * u); // 弧长参数 u ∈ [0,1] → 圆弧
bz = r * (1.0 - Math.cos(a * u)); // 离开纸面的抬升 z
}
u 是沿页宽方向的参数(0 = 书脊装订边,1 = 自由边)。注意 bx = r·sin(a·u) 保证了从 0 到任意 u 的弧长恰为 u·pageW——纸没有被拉伸,只是弯了。这是观感「像纸」的数学根源。
弯曲幅度随进度变化用 bump(t) = sin²(πt):翻页开始和结束时纸是平的(和静止页面无缝衔接),中段最弯。这个 C1 连续的鼓包函数避免了「起翻瞬间突然弯折」的跳变。
3.3 书脊旋转与自由角下垂
弯曲后的截面再绕书脊竖轴旋转:
const phi: number = Math.PI * bookFlipSmoothstep01(tc) + tilt * (v - sgv);
const c: number = Math.cos(phi);
const s: number = Math.sin(phi);
const xr: number = bx * c - bz * s;
const zr: number = bx * s + bz * c;
const wx: number = spineX + d * xr; // d = ±1(前向翻右页 / 后向翻左页)
φ 从 0 到 π,对应页从右半册荡到左半册。tilt·(v − grabV) 是角点折角:离手指捏住的高度位置 grabV 越远的纵向位置,旋转越提前一点,模拟捏着一角翻页时纸的扭转。grabV 直接从手指落点的 Y 坐标换算,拖动时实时更新。
再加一点轻微的自由角下垂(sag),离捏点越远的角坠得越多:
const sag: number = 0.04 * bump * u * Math.abs(v - sgv) * pageH;
const wy: number = pageTop + v * pageH + sag;
3.4 针孔投影:z=0 处 1:1
世界坐标 (wx, wy, wz) 最后过一次针孔相机投影到屏幕:
export function bookFlipProjectPoint(
wx: number, wy: number, wz: number,
stageW: number, stageH: number, fovY: number = BOOK_FLIP_FOV_Y
): [number, number] {
const cam: number = bookFlipCamDist(stageH, fovY); // H / (2·tan(fov/2))
const depth: number = cam - wz;
const scale: number = cam / depth;
const cx: number = stageW * 0.5;
const cy: number = stageH * 0.5;
return [cx + (wx - cx) * scale, cy + (wy - cy) * scale];
}
标定有一个精心设计的约束:相机距离使 z=0 平面与屏幕 1:1 对应(camDist = H / (2·tan(fov/2)),与 Flutter 版同款)。这意味着静止页面(z=0)绘制出来与 drawImageRect 平铺完全重合——翻页动画从平板「长」出弯曲,而不是整体先缩放一下再开始弯。
3.5 mesh 分辨率
顶点网格 14×10(probe 阶段是 24×18,组件化后降下来保帧率)。每个顶点跑一遍上面的两阶段变换 + 投影,纯 ArkTS 计算量完全可接受。
四、渲染性能:atlas + 深度排序 + 一次 drawVertices
几何有了,怎么画得快?这是 BookFlipPainter.ets 解决的问题,也是整个实验里工程密度最高的部分。
4.1 正反面 atlas:一个 ImageShader
翻动中的页(leaf)有正反两面:前向翻页时正面是当前右页、背面是下一张左页。朴素做法是两次绑定两张纹理分别画——但 drawVertices 的纹理来自 Brush 上的 ShaderEffect,一次调用只有一个 shader。
解法是把正反面拼进一张横向 atlas,左右各占一半:
// Pack front | back side-by-side once per leaf pair → single ImageShader.
const atlas: image.PixelMap = image.createPixelMapSync({ size: { width: tw * 2, height: th }, ... });
const off: drawing.Canvas = new drawing.Canvas(atlas);
off.drawImageRect(frontMap, { left: 0, top: 0, right: tw, bottom: th });
off.drawImageRect(backMap, { left: tw, top: 0, right: tw * 2, bottom: th });
const shader: drawing.ShaderEffect = drawing.ShaderEffect.createImageShader(
atlas, drawing.TileMode.CLAMP, drawing.TileMode.CLAMP, sampling, null);
const brush: drawing.Brush = new drawing.Brush();
brush.setShaderEffect(shader);
atlas 按「当前 leaf 页对」缓存(key 是 leafFront_leafBack 页号),翻页期间帧帧复用;idle 时主动释放,不占 GPU 内存:
} else {
// Idle: drop atlas to free GPU memory between flips.
if (this.scene.leafAtlas !== undefined) {
releaseLeafAtlas(this.scene);
}
}
4.2 深度排序:三角形汤
弯曲的页会自己遮住自己——卷过去的部分在后,还没卷过去的部分在前。drawVertices 不做深度测试,所以需要 CPU 侧排序。
做法:把 mesh 网格拆成三角形,按每个三角形的平均世界 z(越大离相机越近)排序,然后按深度顺序展开成 triangle soup,一次 drawVertices 画完:
canvas.drawVertices(
drawing.VertexMode.TRIANGLES_VERTEXMODE,
soupN,
posArg, // 屏幕坐标(已按远近排序展开)
texArg, // atlas UV(左半正面 / 右半背面)
null,
soupN,
idxArg,
drawing.BlendMode.SRC_OVER
);
排序用插入排序——这不是偷懒,而是刻意选择:三角形深度顺序帧间几乎不变,插入排序在「基本有序」输入上是 O(n) 的,比每次快排更划算。
4.3 正反面 UV 镜像
纹理是按「阅读方向」制作的(内容左边在 u=0)。mesh 的局部 u=0 永远是装订边:右页内容的装订边就是内容左边(不镜像),左页内容的装订边是内容右边(需要 1−u 镜像)。哪个面携带左页内容由方向决定,收敛成一个纯函数:
export function bookFlipMirrorU(dir: number, faceFront: boolean): boolean {
if (dir > 0) {
return !faceFront; // 前向:正面=右页(不镜像),背面=下一左页(镜像)
}
return faceFront; // 后向:正面=左页(镜像),背面=上一右页(不镜像)
}
配套规则:静止的左页永远不做 canvas 镜像,内容保持 LTR。否则「翻完落定」瞬间文字会翻转跳变。
4.4 热路径零分配
中段翻页时每帧都要重建 soup。Painter 在 scene 上挂了一组 scratch 数组(soupPos / soupTex / soupIdx / triI0 / triI1 / triI2 / triZ / triFront / triOrder),长度够就原地覆写,不够才 push 扩容。帧间复用,热路径基本零 GC。
另外早期版本每帧还算一遍顶点法线做「形体明暗」(form shading),后来确认绘制路径根本不用它——纯浪费,直接删掉。性能优化第一步永远是删掉不做功的计算。
五、状态机与手势:spread、drag、commit
渲染之外,另一半是「翻到哪了、松手往哪走」。全部实现在 BookFlipCore.ets,同样纯函数可单测。
5.1 spread 双页映射
开本双页模型:pageCount 补齐为偶数,spread s 静止时左页 = 2s、右页 = 2s+1。翻动中(active=true)的页映射有个精妙点——底页要换成「落点页」:
前向翻页(从 spread s 出发):
baseLeft = 2s 左底页不动
baseRight = 2s + 3 ← 右底页换成下一 spread 的右页!
leafFront = 2s + 1 翻动页正面 = 当前右页
leafBack = 2s + 2 翻动页背面 = 下一左页
后向翻页:
baseLeft = 2s − 2 ← 左底页换成上一 spread 的左页
baseRight = 2s + 1
leafFront = 2s 翻动页 = 当前左页
leafBack = 2s − 1 背面 = 上一右页
为什么要换底页?因为掀起当前页的瞬间,下面露出来的应该是落点页。前向翻页时右半册马上要显示 2s+3,而不是还压着 2s+1(否则翻完瞬间会闪一下)。这个细节 Flutter 参考实现里对应 _applyPageMap,是「翻起来不穿帮」的关键。
5.2 拖拽 → 进度
水平拖拽映射到进度,方向约定:前向翻页向左拖、后向翻页向右拖,progress 都增加:
export function bookFlipDragToProgress(
startProgress: number, dxPx: number, pageWidthPx: number, direction: FlipDirection
): number {
const sign: number = direction === FlipDirection.Forward ? -1 : 1;
return bookFlipClamp01(startProgress + (sign * dxPx) / w);
}
手指 Y 坐标同时写入 grabV,驱动角点折角——捏页脚翻和捏页中间翻,纸的扭转形态不一样。
5.3 松手:速度 + 位移双判据
松手后 commit(翻过去)还是回弹,由 bookFlipDecideCommit 决定,三个判据按序短路:
-
边界 peel 永远回弹(
atBoundary时 target 直接 0); -
甩动速度超阈值:快甩必 commit / 反向快甩必回弹;
-
速度不足时看位移外推:
projected = p + velocity · lookAhead,超过commitThreshold(约半页)才 commit。
边界处还做了软性阻尼:peel 进度封顶 0.12,最后一页还能掀起一个角,但怎么拽都翻不过去——和真书的物理直觉一致。
5.4 Controller:引用 attach,禁止 @Prop
程序化 API(nextSpread / previousSpread / goToSpread / openBook / closeBook)沿用 E017 Coverflow 确立的 Controller 引用 attach 模式:
// 组件内
this.controller.attach(handlers); // handlers 是一组闭包
// 宿主侧
bookFlipController.nextSpread();
配套硬规则:PixelMap 与 Controller 一律引用传递,禁止 @Prop。@Prop 的深拷贝会破坏 native 句柄,是本项目多个实验反复验证过的坑(E017 渲染绑定、E018 PixelMap 同源问题)。页列表用 pagesHolder 包装 + pagesEpoch 计数器触发重绑:宿主替换 holder.items 后 epoch++,@Watch 里重新绑定纹理,不卸载组件、不黑屏闪断。
5.5 动画:createAnimator 驱动
松手后的 spring 动画用 getUIContext().createAnimator(options),onFrame 里更新 progress 后 invalidate()。一个实战细节:animator 归一化到 0..1 再 lerp,而不是直接把 begin/end 设成进度值——不同起点的动画参数更好算,时序也更稳:
animator.onFrame = (value: number): void => {
const t: number = value < 0 ? 0 : (value > 1 ? 1 : value);
this.driver.progress = start + (end - start) * t;
this.driver.active = true;
this.invalidate();
};
六、封面开合:一个被低估的状态机
「合上的书 → 点封面 → 打开」是翻书体验的灵魂,也是最容易做出穿帮的部分。E019 的 Cover 增强把这套逻辑固化成 BookOpenState 状态机:
Closed → (点封面 / openBook) → Opening → Open
Open → (closeBook / 首页 Prev) → Closing → Closed
几个真机打磨出来的关键决策:
6.1 合上时书「睡」在左侧
闭态不是把书摆在中间,而是书脊左移一个页宽,封面朝上躺在舞台左半边,左侧画出书棱(页边堆叠)+ 投影。coverProgress 0→1 时书脊从左休位滑回中心,同时封面 leaf 做前向卷曲打开——一个进度驱动两个动作,画面才不会「先挪窝再开门」。
6.2 关书时的正反面语义(最容易错的一条)
打开时 leaf 正面 = 封面、背面 = 第一页,这很好理解。关书时的指派是反直觉的:
打开(coverProgress 0→1):front = 封面,back = pages[0]
关闭(coverProgress 1→0):back = 当前左页,front = 封面
为什么?关书是「当前左页整体合上,合拢后你看到它的背面才是封面」。如果把左页先换成封面再播放动画,就会出现「内容突然变成封面,然后再合一次」的假动作——两次视觉事件,穿帮。
6.3 underlay 禁忌
开合过程中永远不要平铺画出左页。左页内容就是 leaf 的背面,平铺再画一遍轻则重影,重则「像已经打开了又翻一次」。右底页倒是要画(打开时用 pages[1],关闭时用当前右页)。
6.4 书棱渐隐
书棱(页边堆叠)的宽度和透明度随 coverProgress 用 smoothstep 同步渐隐,禁止在 0 附近硬切。渐隐曲线刻意「先保持满厚一小段,再在 0.05~0.55 区间缓出」,避免开书第一帧书棱突然消失。
6.5 关书不回第一页
closeBook() 从任意 spread 直接合上(underlay 用当前页),合拢结束后才把 spread 重置为 0,下次打开从第一页开始。先跳回第一页再关,是两个动作的穿帮组合。
七、刚性变体 E021:同一状态机的另一种纸
E021 验证的是硬板书:页全程保持平面,只绕书脊 0°→180°,不发生卷曲。
数学上它是 E019 的严格子集——把弯曲幅度设零即可:
E019 Soft Curl: developable bend(a = amax·sin²πt) + spine rotate(φ)
E021 Rigid Flip: plane(a = 0) + spine rotate(φ)
工程收益立现:
-
状态机 100% 复用
BookFlipCore(spread 映射、drag、commit、settle 一个字不改); -
渲染路径复用
drawVertices+ atlas(只是 mesh 顶点来自刚性几何); -
mesh 可以降到 2×2——平面不需要密网格,四个顶点两个三角形就够,比柔性版的 14×10 少两个数量级的顶点。
单测还锁定了「叶子四角共面」这一刚性不变量。这就是好分层的红利:换一种纸,只换几何核心。
产品分界:
|
用哪个 |
|
|---|---|
|
杂志、小说、纸感阅读器 |
E019 柔性卷曲 |
|
硬板童书、卡牌收集册、相册板 |
E021 刚性翻转 |
|
卡片轮播、封面墙 |
E017 Coverflow(整卡 rigid,进度语义不同) |
八、内容从哪来:静态纹理是底线,snapshot 是出路
翻页中的页必须是静态纹理(PixelMap),不能是活的组件树——mesh 弯曲的是纹理,不是布局。页内容三条路:
-
调用方自备 PixelMap 列表(主路径);
-
程序生成色块页(Lab 测试用,
createBookFlipLabPagesSync); -
富文本页快照:
UIContext.getComponentSnapshot().createFromBuilder,把 256×360 的@Builder渲染成 PixelMap——这是「真内容进书」的通路,对应 Flutter 的RepaintBoundary.toImage。
内存规则必须写死:换源、换页数时延迟 release 旧列表(正在画的帧还引用着),组件 aboutToDisappear 释放 holder 和 atlas。
九、踩坑清单(拿走不谢)
最后把全路径的坑浓缩成一张表:
|
# |
坑 |
现象 |
解法 |
|---|---|---|---|
|
1 |
|
调用成功、画面全黑 |
改 |
|
2 |
px/vp 混用 |
画面缩在左上角 |
一律用 |
|
3 |
手写缓冲色序 |
红蓝对调 |
按 BGRA_8888 写缓冲 |
|
4 |
异步 PixelMap 不刷新 |
一直黑屏 |
invalidate + 补发重绘;必要时 epoch 重建 |
|
5 |
|
native 句柄失效 |
holder + epoch 引用重绑 |
|
6 |
翻动中底页穿帮 |
翻完闪一下 |
active 态底页换落点页( |
|
7 |
左页镜像跳变 |
落定瞬间文字翻转 |
只有 leaf UV 按面镜像,平铺页永 LTR |
|
8 |
弯曲页自遮挡错乱 |
前后面穿插 |
三角形平均 z 深度排序 + soup |
|
9 |
每帧建对象 |
中段卡顿 |
scratch 数组复用 + 删不做功的 shading |
|
10 |
关书先换封面 |
「假动作」穿帮 |
关闭时 leaf back = 当前左页 |
|
11 |
书棱硬切 |
开书瞬间闪灭 |
宽度+透明度 smoothstep 同步渐隐 |
|
12 |
父容器抢手势 |
拖不动 |
手势区移出竖向 Scroll, |
结语
回看这条路径,真正的分歧点其实在 P0:如果当时没有先做能力闸门,直接按「官方有 drawPixelMapMesh」的假设写完整个组件,真机首测才会发现整条渲染路径不可用——返工成本是全部组件代码。先用最小 probe 验证最不确定的平台能力,再投入工程,这次又赢了一次。
而几何核心(可展弯曲 + 书脊旋转 + 1:1 针孔投影)和状态机(spread 映射 + commit 决策)因为保持纯函数、零 UI 依赖,不仅被 E021 刚性变体整体复用,也和本系列《自定义 Canvas 动效的分层范式》里的 Core/Painter/Component 四层完全同构。算法与视图分离,不是架构洁癖,是复用的前提。
更多推荐



所有评论(0)