Qihang Yang

I love creating stuff, generally.

I've been building Drifting for a while. It writes with you and helps you organize your thoughts into stories.

I started off as a frontend enthusiast because I like good products, and the process of making them.

I also dig lower-level stuff. A lot of the ideas are truly ingenious, elegant, and timeless.

Besides coding, I'm a hobbyist writer. I also enjoy comedy and country music.

You can find me on GitHub, LinkedIn, or .

为什么“跟手”不是一条 easing 曲线

有阳光。
    -《柿子》,林忆莲歌曲

地铁上看书时,我总想要一个跨端的,翻页跟手、自然的阅读器。

Google Play Book 的翻页动画很跟手自然,但是不稳定。而且慢,iOS 端的翻页动画是阉割版。 Apple books,动画不太跟手。两者都是各自生态中才好用的。

于是,打算自己做一个,翻页舒适、自然的阅读器。

简单的。纯粹的一个。柿子,这个名字闪现在我脑中,就像林忆莲的歌曲所唱的,有阳光。

书,知识,带给人的感觉,不正是阳光吗?

柿子阅读
这个 app 已经上线,完全免费。

最开始做翻页动画时,我以为问题很简单:把一张矩形沿着圆柱弯起来,再配上一条舒服的 easing,就能得到一个像样的效果。

真正开始做之后,我才发现,纸怎么弯,反而是整个系统里相对简单的部分。

我想要的不只是“播放一段翻页动画”。我希望手指按下时纸张立刻跟上,拖到一半可以反悔,快速连续点击时每一张纸都能自然叠起来,松手后动画还能无缝接管。与此同时,页面下面是真实的 EPUB 文字、图片、字体和分页结果。

最后要协调的,不再是一条动画曲线,而是输入、几何、内容准备、渲染线程和资源生命周期。

柿子的连续翻页效果

第一张纸

我先在一个独立的 WebGPU/WebGL2 原型里做实验。

第一版采用了最直观的圆柱模型:页面贴着一个圆柱卷过去。它确实“会弯”,运行也很稳定,但逐帧对照真实翻书录屏时,问题非常明显——它更像一根硬滚筒扫过屏幕,不像一张纸。

人翻书时,要不,拿着纸,抡过去;要么,手指按住,搓着纸,不断移动。直到,纸卷成筒,散开,打平。

我想要后者。

于是我换成了一条连续曲率的纸张轮廓。每一帧都从一组确定的切线方向重新构造页面,不引入质量、重力、碰撞或长期积分状态。纸张不可伸长,只是从按压、成卷、越过书脊,逐渐过渡到在另一侧铺平。

这给了我第一个启发:自然感不一定来自更复杂的物理模拟,很多时候,它来自更正确的约束。

几何对了,手感还是不对

纸张看起来像纸之后,交互仍然有很多奇怪的地方。

页面卷到书脊附近会自动释放,但用户明明还按着手指;相同的拖动距离,因为起始落点不同,会产生不同的纸形;固定像素的铰链行程,在手机和平板上又是两种手感;已经卷得很紧的纸,回弹速度却和轻轻拱起的纸一样。

这些问题最终没有靠继续修改 easing 解决,而是靠重新定义输入和纸张之间的关系:

  • 只要手指没有抬起,控制权就始终属于手指;
  • 落点只是局部坐标的原点,真正决定纸形的是相对移动;
  • 铰链行程从页面几何中推导,而不是写死一个像素值;
  • 松手时,再综合距离、速度和加速度判断完成翻页还是回弹;
  • 所谓“纸张重量”只改变提交门槛,不偷偷改变拖动过程中的几何。

我慢慢意识到,“跟手”首先意味着可预测。用户做出相同的动作,纸张就应该给出相同的回应,中间不能藏着突然释放、坐标跳变或者状态切换。

把原型放进真实 Reader

独立原型只需要处理一张纹理,真正的 Reader 还要面对 EPUB 解析、字体、图片解码、分页和章节切换。

我曾经用 Readest 当作主力阅读 app 一段时间,但是他的翻页动画是提取的系统 WebView 截图,导致无论怎么优化,翻页都很难跟手。当时我一直关闭翻页动画使用,但心里总痒。

所以,柿子从一开始就没有使用 WebView。内容先被编译成平台无关的数据,再由 Skia 直接排版、绘制。最初我甚至把“页面不是截图”写进了架构原则,因为我不希望正文变成一张失去清晰度和交互能力的位图。

但纸张发生复杂形变时,直接弯曲整棵实时文字和图片渲染图又不现实。

最后找到的方案是一种混合架构:静止页面始终由 Skia 实时绘制,只有正在翻动的那张纸会被临时记录和栅格化;动画结束后,画面重新回到实时页面。

这不是对最初原则的否定,而是把边界定义得更准确了:截图不能成为阅读热路径,但可以成为短暂的动画材质。

更多 lane 并没有让系统真正变快

单次翻页跑通以后,我开始测试快速连续点击。

最直觉的办法是增加动画 lane,让更多纸张同时飞在空中。lane 一度越加越多,表面上的排队少了,纹理驻留、页面捕获和内存开销却一起上涨,首帧仍然不一定及时出现。

那时我才意识到,并发槽位解决的是容量,不是延迟。

即使调度器每 100ms 启动一张纸,在用户真正看到它之前,仍可能经过 React 状态更新、页面记录、栅格化、纹理安装、组件提交和 Canvas 首次绘制。把间隔设成 100ms,并不代表输入后 100ms 就一定能看到有效画面。

真正的转折,是把动画的实时控制下沉到一个持久的原生 compositor:相邻页面提前进入一个有预算的双向库存;页面已经 warm 时,手势直接进入 UI/native 路径;JavaScript 负责分页、补充库存和最终位置对账,不再逐帧驱动画。

性能提升来自缩短关键路径,而不是单纯把 duration 调短。

纸变成了空白

开发过程中最让我印象深刻的一次失败,发生在一个纹理优化实验里。

我尝试让 GPU snapshot 直接跨 Canvas 复用,从而跳过 CPU readback 和重新上传。类型检查通过,测试通过,bundle 也通过,但到了真机上,翻动中的纸张纹理直接消失了。

原因是 GPU-backed snapshot 仍然依赖创建它的 Skia context,跨 context 并不天然可采样。最后我恢复了 portable raster:先生成 snapshot,再转成不绑定原始纹理上下文的 image。

这件事让我意识到了 coding agent 的局限。

后端开发,一个看不到的服务,正确性容易证明。只要有深刻的业务逻辑理解即可。

但前端不同。

测试通过,看起来正确的 log 可以证明代码逻辑没有坏,却无法证明用户真的看到了应该看到的东西。

渲染系统最终必须在真实设备上验收。

这促使我后续不断研究 coding agent 真机测试、验收的可能性。

也许前端已经死过几次,但总有些刁钻的,肉眼才能敏锐察觉的问题,延缓着它的下次死亡。

越大就越...

系统可以高速运行后,又遇到了更隐蔽的问题:长时间连续翻页会触发内存压力,甚至 Hermes OOM。

罪魁祸首不是某一张特别大的纹理,而是大量 Paragraph、Picture、recording graph,以及布局变化时同时存在的新旧 generation。更麻烦的是,有些原生对象虽然在业务逻辑里“已经没用了”,仍然可能被下一帧的 display list 引用。

最后的解决方案不是一次性清空缓存,而是建立明确的退休制度:

Paragraph 和 recording 使用有预算的 LRU;分页计数阶段不长期保留排版对象;旧 generation 先停止使用,等待足够的绘制机会后再释放;native page stock 对仍在动画中的页面承担明确的所有权;空闲 lane 和 display link 必须真正休眠。

打出一发漂亮的翻页动画并不难。让上一张、下一张,以及一百张以前的纸都在正确时刻消失,才是系统真正稳定的开始。

第一次把“感觉很快”变成数字

写到这里时,我在 iPhone 17 Pro Max 的 Release 构建上录了一份 76 秒的 Instruments Game Memory trace。实际连续提交 Metal 画面的区间约为 33 秒,之后又留了约 30 秒不操作,用来观察库存和临时纹理能不能退回稳定平台。

Persimmon 的 Instruments Game Memory 时间线

图:蓝色是 Metal 资源事件,紫色是 GPU 活动;翻页停止后,GPU 退出持续工作,VM 曲线没有继续向上爬。

这次 trace 里,MTLDevice.currentAllocatedSize 的首个样本是 296.14 MiB,峰值是 311.16 MiB,静置后的最终值是 296.11 MiB。也就是说,测试期间的 Metal 峰值比稳定平台多了约 15.02 MiB,恰好接近一张 1320 × 2868、RGBA8 全屏纹理;测试结束后,这部分增长没有被继续保留。

为了继续回答“到底有多快”,我又录了一份完整保留 40.92 秒时间轴的 Game Performance trace。连续 compositor 活动出现在第 7.29 到 33.09 秒,共 25.79 秒、2,976 次真正上屏。相邻上屏间隔的 P50 是 8.334 ms,P95 是 8.334 ms,P99 是 16.668 ms,最大值是 25.002 ms;中位节奏约为 119.99 Hz,整段平均呈现率是 115.34 fps。

Persimmon 的 Instruments Game Performance 时间线

图:绿色区域显示热状态始终为 Nominal;紫色是 GPU 活动;放大的 Display 1 轨道显示连续切换的三缓冲 surface 与实际 VSync。

这里最重要的不是把所有超过 8.33 ms 的间隔都算成掉帧,而是和当时屏幕真正产生的 VSync 逐一对账。这个区间共有 3,034 个实际刷新机会,其中 59 次没有新的 Persimmon surface,miss ratio 为 1.94%;最大连续 miss 只有 1。112 个约 16.67 ms 的上屏间隔里,有 57 个只是系统自己临时切到了约 60 Hz,并不是 App 漏掉了一帧。

同一份 trace 也捕获到了 Persimmon 自身的 Metal 数据。以 2,974 个逻辑帧为样本,把每帧内所有 App GPU 活动区间合并后,GPU work 的 P50 是 4.96 ms,P95 是 8.41 ms,P99 是 9.74 ms;Metal encoder time 的 P50 是 0.18 ms,P95 是 0.88 ms,P99 是 1.98 ms。GPU 的 P95 略高于 120 Hz 的单帧预算,也和少量 VSync miss 相互印证:系统很快,但尾部仍然有继续优化的空间。

反向翻页也不是简单镜像

后面处理快速反转时,我又踩过一次“看起来理所当然”的坑。

正向翻页,是当前页被掀开,目标页从下面露出来;反向翻页,却是目标页从左侧盖回来,当前页应该仍然留在下面。两者的页面 ownership、背景和绘制顺序并不是简单左右镜像。

很多看起来像“动画速度不对”的闪帧,最后发现其实是背景页选错、旧方向的 visual tail 没有退休,或者页面库存被过早消费。继续缩短 duration 只会掩盖问题,无法修复语义。

我最后理解的“跟手”

现在回头看,柿子的翻页体验并不是由某一个神奇公式带来的。它来自几件事情同时成立:

  1. 手指和纸张之间存在稳定、可预测的几何关系;
  2. 用户开始动作之前,正确的页面内容已经准备好;
  3. 动画开始后,由一个不再依赖 React 逐帧调度的渲染器持续拥有它;
  4. 动画结束后,纹理、段落和旧 generation 能在安全的时刻退休。

目前的真机测试已经覆盖连续点击、拖拽回弹和持续翻页,也保存了 Game Memory 与 Game Performance 两组基线。

至少对这一次 2026-08-13 的测试 trace,我可以具体地说:连续绘制区间平均呈现 115.34 fps,98.06% 的实际 VSync 都拿到了新 surface,而且没有连续漏掉两个刷新机会。

但这仍然不等于“稳定 120 fps”的断言。也不是输入到首帧的 P95。在把 native input、present 和 turn completion 接进同一条 signpost 时间线之前,我宁愿保留这个边界。

但至少,我已经不再把翻页理解成一条 easing 曲线了。

它更像一台很小的渲染器:提前准备正确的纸,在手指落下时立刻交出来,然后在所有人都不再需要它的时候,把它收走。