一、目标:不要假立体

先明确「真翻书」和「假立体」的区别。

最常见的偷懒做法是:左右两个半页容器,翻页时对右半页做 .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.canvasdrawing.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

真机结果

drawImageRect

✅ 平铺/双页正常

drawPixelMapMesh

❌ 不抛错、不绘制(Mesh only 全黑)

drawVertices + ImageShader

主路径,卷曲可见

所以主路径锁定为: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_flipBookFlipMesh._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 决定,三个判据按序短路:

  1. 边界 peel 永远回弹atBoundary 时 target 直接 0);

  2. 甩动速度超阈值:快甩必 commit / 反向快甩必回弹;

  3. 速度不足时看位移外推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.itemsepoch++@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 弯曲的是纹理,不是布局。页内容三条路:

  1. 调用方自备 PixelMap 列表(主路径);

  2. 程序生成色块页(Lab 测试用,createBookFlipLabPagesSync);

  3. 富文本页快照UIContext.getComponentSnapshot().createFromBuilder,把 256×360 的 @Builder 渲染成 PixelMap——这是「真内容进书」的通路,对应 Flutter 的 RepaintBoundary.toImage

内存规则必须写死:换源、换页数时延迟 release 旧列表(正在画的帧还引用着),组件 aboutToDisappear 释放 holder 和 atlas。


九、踩坑清单(拿走不谢)

最后把全路径的坑浓缩成一张表:

#

现象

解法

1

drawPixelMapMesh 静默失效

调用成功、画面全黑

drawVertices + ImageShader

2

px/vp 混用

画面缩在左上角

一律用 drawContext.sizeInPixel

3

手写缓冲色序

红蓝对调

按 BGRA_8888 写缓冲

4

异步 PixelMap 不刷新

一直黑屏

invalidate + 补发重绘;必要时 epoch 重建

5

@Prop PixelMap

native 句柄失效

holder + epoch 引用重绑

6

翻动中底页穿帮

翻完闪一下

active 态底页换落点页(2s+3 / 2s−2

7

左页镜像跳变

落定瞬间文字翻转

只有 leaf UV 按面镜像,平铺页永 LTR

8

弯曲页自遮挡错乱

前后面穿插

三角形平均 z 深度排序 + soup

9

每帧建对象

中段卡顿

scratch 数组复用 + 删不做功的 shading

10

关书先换封面

「假动作」穿帮

关闭时 leaf back = 当前左页

11

书棱硬切

开书瞬间闪灭

宽度+透明度 smoothstep 同步渐隐

12

父容器抢手势

拖不动

手势区移出竖向 Scroll,distance: 2 快速响应

结语

回看这条路径,真正的分歧点其实在 P0:如果当时没有先做能力闸门,直接按「官方有 drawPixelMapMesh」的假设写完整个组件,真机首测才会发现整条渲染路径不可用——返工成本是全部组件代码。先用最小 probe 验证最不确定的平台能力,再投入工程,这次又赢了一次。

而几何核心(可展弯曲 + 书脊旋转 + 1:1 针孔投影)和状态机(spread 映射 + commit 决策)因为保持纯函数、零 UI 依赖,不仅被 E021 刚性变体整体复用,也和本系列《自定义 Canvas 动效的分层范式》里的 Core/Painter/Component 四层完全同构。算法与视图分离,不是架构洁癖,是复用的前提。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐