简介:面向Android开发者的翻书效果实现资源,以“双开”双页展示为核心,系统覆盖页面视图布局、属性动画组合、3D图形渲染、手势滑动检测及性能优化等实现要点,并从事件分发到特效渲染均有涉及,适合电子书应用或多页内容展示场景的中高级开发者参考学习。资源内含CurlActivity核心源码、可直接运行的APK、配套Java类与XML布局,以及PNG/JPG图片素材、README说明文档和工程配置文件,便于对照分析和二次开发。压缩包共63个文件,主要类型为22个class字节码、12个png图片、7个java源码、5个xml配置、6个jpg图片及APK安装包等,整体大小仅3.97MB,目录结构清晰。已有481人学习浏览,可用于快速理解翻书动画的完整实现链路,并借鉴其手势交互与渲染优化思路。
1. android翻书效果(双开):难点不在「翻」,在「背面」
「android翻书效果(双开)」是阅读器、画册类应用常见的双页展开模式:一屏同时渲染左右两页,翻页时整张纸像真实书本一样从一侧卷起,露出下面的页面。很多开发者把单页翻页 Demo 跑通后直接改成双开,结果立刻破功——问题往往不在「翻」的动作,而在两个被忽略的细节:纸张的背面画什么,左右两页如何联动。
这篇文章把双开翻书从选型到落地讲清楚:先说明技术路径和判断标准,再给一版可以直接跑通的核心绘制代码,然后是接入项目时的参数与内存处理,最后列出最容易踩的五个坑。适合已经会自定义 View 绘图、准备做阅读器或图文仿真界面的开发者;如果你只是想知道这个效果值不值得做,直接看下一章的选型部分就够了。
2. 双开翻书先过选型关:裁剪、网格变形还是纹理贴图
翻书效果在 Android 上绕不开一个问题:把「纸被拎起来」这件事翻译成画面,到底用哪种图形手段。双开模式比单页多一页内容,选型一旦错了,后面返工的成本会成倍放大。这一章把三种主流路径摊开讲,并给出我的判断顺序。
2.1 三种技术路径的对比:从原理到能接受的代价
第一种是自定义 View 裁剪。做法是把每个页面画成一张静态纹理,翻页时用贝塞尔曲线算出页角形状,再用 Canvas.clipPath 裁出「正在翻起」的区域,在这个区域内绘制旋转或镜像后的纹理。这是最直观的实现,开发成本低,一版可用的双开效果通常两三周能调出来;代价是页面内容动态变化时需要重新生成纹理,而且纯 CPU 绘制在低端机上会吃力。
第二种是网格变形。把一张纸划分成 n×m 的网格,每个网格点保存「纹理坐标」和「几何坐标」两组数据,翻页时偏移顶点位置,模拟纸张卷曲。它比平面裁剪更接近真实纸张的弧度,但难点在于顶点的位移量、网格密度、纹理跟随这三者要同时调好,很容易出现「页角翘起来了、纹理还贴在原处」的撕裂感。网格密度越大越精细,计算量也越大。
第三种是 OpenGL 纹理贴图。把左右两页分别贴到平面网格上,在着色器里做卷曲、光照和阴影。这是效果上限最高的方案,动画可以做到非常柔软,CPU 占用也低,但工程链路明显变长:要管理 GL 上下文、纹理上传、资源释放,双开的左右页状态还要自己做同步。对没有图形基础的团队,我不建议把 GL 当作首版方案。
| 路径 | 开发成本 | 效果上限 | 动态内容支持 | 双开适配 |
|---|---|---|---|---|
| 自定义 View 裁剪 | 低 | 中等 | 需重新出纹理 | 天然适合,左右页分别绘制 |
| 网格变形 | 中 | 较高 | 需重新出纹理 | 需要把左右页放进同一张网格 |
| OpenGL 贴图 | 高 | 高 | 纹理更新灵活 | 状态管理最复杂 |
表格里的「动态内容」指页面里是否有滚动列表、视频、地图这类不适合静态纹理的对象。我的选型习惯是:没有明显性能瓶颈的团队,先从自定义 View 裁剪入手,因为双开场景下「左右页联动」这件事在裁剪方案里非常直观;跑通之后如果发现卷曲弧度不够真实,再考虑把绘制层替换成网格或 GL。不要第一版就上 OpenGL,除非项目本身已经有一套成熟的图形渲染管线。
2.2 双开比单页多的两个变量:左右联动与纸背纹理
双开模式下,屏幕被中缝分成左页区和右页区,翻页在物理上天然存在两个方向:翻右页时纸从右向左卷,翻左页时纸从左向右卷。如果只拿单页 Demo 改,最常见的误用是把左页当作右页「裁反了」,效果变成整张纸镜像着滑动,看着像幻灯片切换而不是翻书。正确做法是把「翻页方向」抽象成一个状态,所有几何计算先乘方向系数再参与运算,左右两页共用同一套绘制方法。
第二个变量是纸背纹理。真实的书页被拎起来时,你看到的不是当前页内容的镜像,而是这张纸另一面的内容。实践里不少开发者图省事,直接把当前页 Bitmap 做水平翻转当成纸背,结果文字方向对了,内容却和真实翻书对不上。正确模型是给每张「纸」维护正反两面纹理,正反内容由业务数据决定,翻页过程中纸背层绘制的是该纸的反面纹理,而不是当前页镜像。
这里还牵扯到中缝和阴影的关系。双开模式下纸背卷起时,中缝附近应该出现一条由纸张厚度产生的暗部,否则画面会显得「薄得像一张卡片」。阴影不一定在初版就做,但数据模型里要预留阴影层的绘制顺序——它应该夹在纸背和底部内容之间,而不是盖在最上面。
2.3 选型结论:什么场景值得上双开,什么场景别硬上
翻书效果本质上是仿真,不是效率工具。适合上双开的场景有阅读器、精美画册、数字书单、儿童绘本,用户对「像一本书」的体验有明确期待,页面内容以静态排版和图片为主,纹理生成成本可控。不值得上的场景是信息流、文章列表、需要频繁刷新内容的页面;如果产品只是想要「过渡动画好看」,用普通水平滑动加缩放旋转就能达到目的,成本低得多。
我在做选型判断时会连续问三个问题:页面内容是否天然分页且稳定?产品文案里是否强调「像书一样」的体验?团队成员有没有动画或图形基础?三个都答「是」才投入双开,否则用单页翻页或者滑动过渡更稳妥。还有一点容易被忽略:双开一旦做了,后续的排版引擎、字体分页、主题切换都要跟着配合,这部分维护成本通常比动画本身更大。某公司的阅读项目就是在双开做了一半时发现分页引擎不支持「按屏幕宽度分两栏」,不得不返工,这个坑在选型阶段就要提前确认。
3. 手动写双开翻页核心:贝塞尔页角与三层绘制
选型确定后,就到了真正动手的阶段。这里给出一版自定义 View 裁剪方案的核心实现,不依赖任何第三方库。整个绘制过程可以拆成三层:底部露出的内容、正在翻起的纸背、还没有翻起的前页。理解这三层的顺序,双开翻页的骨架就立住了。
3.1 先定义好翻页模型:控制点、翻页方向与角度
动手写绘制之前,先定义状态。翻页动画的核心数据是「翻页进度」和「页角翘起比例」,这两个值不要混在一起:progress 代表整张纸翻到哪个程度,0 表示平放,1 表示翻完;curlRatio 控制页角抬起后最高能翘到页面宽度的百分之几,通常取 0.3 到 0.4,太高会显得夸张,太低又看不出立体感。
data class PageTurnState( val direction: Int = 1, // 1 表示从右往左翻右页,-1 表示从左往右翻左页 val progress: Float = 0f, // 翻页进度,0f 未翻起,1f 完全翻过去 val curlRatio: Float = 0.35f, // 页角翘起高度占页面宽度的比例 val touchX: Float = 0f, // 当前手势的触摸点 X val touchY: Float = 0f // 当前手势的触摸点 Y )progress 由手势映射而来,curlRatio 是绘制用的固定参数,两者职责不同。direction 是双开场景的关键:单页翻页不需要区分方向,双开必须通过 direction 把左页和右页的几何计算统一起来,否则代码会变成两份几乎一样但镜像对称的实现。下面这段代码生成页角的裁剪路径,按 direction = 1 的情况简化说明为什么坐标要做镜像。
fun computeCurlPath( width: Float, height: Float, state: PageTurnState ): Path { val curlX = width * (1f - state.progress * state.curlRatio) val curlY = height * (1f - state.curlRatio) val path = Path() if (state.direction == 1) { // 右页:从右向左翻,页角尖端在右侧 path.moveTo(width, 0f) path.lineTo(width, height) path.lineTo(curlX, height) path.quadTo(curlX * 1.2f, curlY * 0.8f, width, 0f) } else { // 左页:从左向右翻,整体镜像 x 坐标 path.moveTo(0f, 0f) path.lineTo(0f, height) path.lineTo(width - curlX, height) path.quadTo(width - curlX * 1.2f, curlY * 0.8f, 0f, 0f) } path.close() return path }这段代码里,curlX 代表页角尖端在屏幕上的水平位置,progress 越大,curlX 越靠近页面左侧,翻起的面积越大。quadTo 的第二个点可以理解成「页角翘起时边缘的弯曲程度」,系数 1.2 和 0.8 是我常用的经验值,想让弧度更大可以把 1.2 调成 1.5,但不要超过 2.0,否则贝塞尔曲线会过冲,页角边缘出现凹陷。direction = -1 时所有 x 坐标取镜像,这样左右页的视觉对称性一致。
3.2 三层绘制顺序:前页、纸背、底页的 Canvas 变换
绘制顺序决定了遮挡关系。底部内容最先画,然后画正在翻起来的纸背,最后画还没有被翻起的前页。这里有一个很容易搞反的点:前页的静止区域应该最后画,因为它要盖住纸背在未被翻起区域的部分;而纸背只能在翻起的裁剪区域内可见。
override fun onDraw(canvas: Canvas) { super.onDraw(canvas) // 第 1 层:底部内容,通常是下一张纸的正页 canvas.drawBitmap(bottomPageBitmap, 0f, 0f, pagePaint) // 第 2 层:纸背,只在翻起区域内可见,绘制时做水平镜像 canvas.save() canvas.clipPath(curlBodyPath, Region.Op.INTERSECT) canvas.translate(pageWidth, 0f) canvas.scale(-1f, 1f) canvas.drawBitmap(curPaper.back, 0f, 0f, pagePaint) canvas.restore() // 第 3 层:前页静止区域,盖住底部内容 canvas.save() canvas.clipPath(stillVisiblePath, Region.Op.INTERSECT) canvas.drawBitmap(curPaper.front, 0f, 0f, pagePaint) canvas.restore() // 第 4 层:页角阴影,强化纸张厚度 canvas.drawPath(shadowPath, shadowPaint) }clipPath 的调用顺序不能颠倒。如果先画前页再画纸背,纸背会把前页的静止区域盖住,视觉上变成纸张从下面穿出来,非常假。pagePaint 建议用 Paint.FILTER_BITMAP_FLAG 开启双线性过滤,否则翻页过程中纹理边缘会有明显锯齿。shadowPath 的画法一般是沿着纸背边缘生成一个渐变区域,透明度从 0x30 递减到 0x00,具体参数在第四章的参数表里展开。
双开模式下,整个 View 的逻辑宽度是屏幕宽度的一半,中缝在 viewWidth / 2 处。左页和右页各自调用一遍上述绘制流程,只是传入的 pageWidth 和坐标偏置不同。我在项目里会把左右两页的绘制封装成一个函数,参数加上 offsetX,这样中缝对齐只靠一处代码控制,不会出现左右页错位半像素的问题。
3.3 触摸映射:把手势坐标转成翻页状态
绘制只负责把 state 变成画面,state 本身来自触摸事件。ACTION_DOWN 时根据触摸点落在左半区还是右半区决定 direction,ACTION_MOVE 时把手指位置换算成 progress,ACTION_UP 时判断要不要翻完还是回弹。换算公式并不复杂,但要注意 progress 的方向:翻右页时,手指越往左滑,progress 越大。
override fun onTouchEvent(event: MotionEvent): Boolean { val halfWidth = viewWidth / 2f when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { isAnimating = false state.direction = if (event.x < halfWidth) -1 else 1 state.touchX = event.x state.touchY = event.y return true } MotionEvent.ACTION_MOVE -> { state.progress = if (state.direction == 1) { (viewWidth - event.x) / halfWidth } else { event.x / halfWidth }.coerceIn(0f, 1f) invalidate() return true } MotionEvent.ACTION_UP -> { if (state.progress >= 0.45f) { startFlipAnimation(true) // 继续翻完 } else { startFlipAnimation(false) // 回弹复位 } return true } } return super.onTouchEvent(event) }这里有个交互上的细节:ACTION_DOWN 时不能立刻把 progress 重置为 0,否则用户手指按下的一瞬间页面会出现「跳变」。正确做法是在 DOWN 时只记录 direction,progress 的起点由当前实际位置决定。0.45 是临界角阈值,我一般取 0.4 到 0.5 之间,小于 0.4 会让用户觉得「翻页太容易误触」,大于 0.5 则需要手指划过超过一半页面才会翻动,显得笨重。这个值和动画时长一样,属于必须实机调校的参数。
4. 从 Demo 到项目:双开翻页的纹理管理与参数调优
Demo 里用的 Bitmap 大多是临时生成的,接进真实项目后,第一个要解决的就是「页面内容从哪里来」。双开模式下同时存在左页、右页、纸背三张纹理,如果再加上预加载策略,内存压力会迅速成为瓶颈。这一章讲纹理生成、内存预算和几个必调参数。
4.1 页面纹理的生成与内存预算:双页双份开销怎么压
纹理生成最常见的错误是按「页面逻辑大小」去创建 Bitmap。逻辑大小是指排版引擎里定义的页面尺寸,比如 720×960,而屏幕实际显示的尺寸可能是 1080×1920,两者不一致时,纹理要么模糊要么浪费内存。我的做法是始终按 View 的实际显示尺寸乘以设备密度缩放来生成,换算公式很简单:targetW = viewWidth * density,targetH = viewHeight * density。
另一个容易忽视的点是 Bitmap 格式。阅读类页面通常没有透明通道,用 RGB_565 而不是 ARGB_8888,内存直接省一半。以 1080×1920 的单页为例,ARGB_8888 约 8MB,RGB_565 约 4MB,双开模式下同时持有前页、纸背、底部页三张纹理,仅这三张就是 24MB 对 12MB 的差距。这个差距在低端机上足够决定是否 OOM。
| 纹理组合 | ARGB_8888 | RGB_565 |
|---|---|---|
| 单页(1080×1920) | 约 8MB | 约 4MB |
| 双开三张纹理 | 约 24MB | 约 12MB |
| 双开三张纹理(按显示尺寸 540×960) | 约 6MB | 约 3MB |
表格里的第三行是项目里常见的降内存手段:按目标区域实际尺寸生成纹理,而不是按内容设计尺寸。画册里的大图可以先做采样压缩再贴上去,采样用 BitmapFactory.Options 的 inSampleSize,不要在生成之后再 scale,后者会额外多占用一份峰值内存。
纹理的复用也很关键。每次翻页都重新创建 Bitmap 会产生大量 GC,卡顿几乎必然出现。我习惯维护一个简单的 BitmapPool:翻完一页后,把不再需要的纹理放回池子而不是立刻回收,下次需要同尺寸纹理时直接从池里取。池的容量控制在三张以内,超过就回收最久未使用的,避免池本身变成内存漏洞。
4.2 复杂页面怎么翻:把 WebView 与滚动列表拍成纹理的常见做法
静态文本页面可以直接排版到 Bitmap,但阅读器里经常有 WebView 渲染的章节、带滚动列表的附录、甚至嵌了地图的页面。这类内容不能直接拿排版引擎出图,常见的做法是把 View 本身绘制到离屏 Bitmap 上,也就是「拍快照」。
fun captureView(view: View, width: Int, height: Int): Bitmap { val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565) val canvas = Canvas(bitmap) // 保证 Canvas 密度和 Bitmap 一致,避免系统默认密度导致内容模糊 canvas.setDensity(bitmap.density) view.draw(canvas) return bitmap }WebView 在有硬件加速的情况下,直接调用 view.draw(canvas) 通常能拿到渲染结果,但前提是 WebView 已经完成布局且目标区域在可视范围内。滚动列表类页面需要先把列表滚动到目标位置,等布局稳定后再 draw,否则截出来的是列表顶部而不是当前页内容。这里有个容易踩的时序问题:滚动是异步的,draw 之前最好 post 到下一帧执行,确保滚动完成后才生成纹理。
PixelCopy 是另一个可选方案,它专门用于从窗口截取内容,API 26 之后可用,但只适合截取屏幕上当前可见的区域。双开翻页需要的内容往往包含「还没显示出来」的相邻页面,所以 PixelCopy 不适合做翻页纹理源,它更适合做「当前页预览图」。项目里如果需要一张封面缩略图,PixelCopy 是好选择;需要整张翻页纹理,还是老老实实走 View.draw。
4.3 必调参数表:翻页时长、速度曲线、临界角与阴影密度
翻页手感由参数决定,不是代码写完就固定的。下面是双开翻书效果里我每次都会过一遍的参数,括号里是一开始可以用的经验值,后面的建议供实机调校参考。
| 参数 | 经验值 | 调校依据 |
|---|---|---|
| 翻页时长 | 300ms | 内容重的页面适当加长到 350ms,太短像快速滑动 |
| 回弹速度曲线 | DecelerateInterpolator | 回弹要「先快后慢」,线性会显得机械 |
| 翻完速度曲线 | OvershootInterpolator(0.2f) | 翻过去之后带一点惯性,0.3f 以上容易过头 |
| 临界角阈值 | 0.45 | 小于 0.4 误触率高,大于 0.5 显得笨重 |
| 阴影起始透明度 | 0x30 | 阴影太深显得页面脏,太浅没有体积感 |
| 页角翘起比例 | 0.35 | 画册可以到 0.4,正文阅读保持 0.3 左右 |
阴影和时长这两项的调校逻辑相反:阴影密度影响「纸的感觉」,时长影响「手的感受」。如果一个页面纹理生成要 80ms,把翻页时长从 280ms 调到 350ms,用户感知到的卡顿会小很多,因为动画本身的跨度给了纹理生成更充裕的时间。与其拼命优化纹理生成速度,不如先给动画留一点余量,这是低端机上最省事的方案。
翻页动画执行期间要锁定状态,不允许再次触发新的纹理生成。双开场景下如果左右页同时翻动,内存峰值会是单次翻页的两倍,所以我在动画开始前会设一个 isAnimating 标志,新的触摸事件在动画结束前不响应,最多记录目标 direction。这个标志在快速连翻的场景下还能避免纹理预加载和动画互相打架,具体行为在下一章「快速连翻动画错乱」里面展开。
5. 双开翻书避坑指南:五条常见翻车现场与排查顺序
翻书效果是个典型的「看起来简单、做起来细节多」的功能。这一章直接列出我在项目里遇到过的五次典型翻车,每条都按现象、原因、解决的顺序讲,排在前面的是出镜率最高的锯齿和反字问题。
5.1 页边锯齿与撕裂:根源多在纹理采样,而不是效率
现象:翻页过程中,页角的曲线边缘出现明显锯齿,翻到一半时页面出现横向撕裂感,特别是图片较多的画册页面格外明显。
原因:clipPath 的抗锯齿只能作用于 Path 的轮廓,纹理本身的采样质量取决于 Paint 和 Bitmap 是否匹配。出现横向撕裂,通常是纹理 Bitmap 的分辨率和显示尺寸不一致,Canvas 缩放时像素没有被平滑滤波;还有一个隐蔽因素,页角裁剪路径是每帧重新算的,路径顶点数量过少时,曲线会被折线化,看起来就像锯齿边。
解决:优先检查 pagePaint 有没有设置 FILTER_BITMAP_FLAG,没有的话先加上。其次把纹理按目标尺寸生成,避免大图硬缩。最后,如果贝塞尔路径的曲率变化大,把路径平滑参数调高一点,不要为了省计算量减少路径采样点。以上三步都做了还有边缘毛刺,就把翻页区域整体放大到 1.5 倍再绘制,最后用 Canvas 的 scale 缩回显示尺寸,锯齿会被硬件缩放滤掉大半。
5.2 纸背文字反了:镜像写错还是数据源用错
现象:翻页过程中,纸背上的文字和图片方向不对。有的整体左右颠倒,有的方向对但内容根本不是这张纸的反面。
原因:这两种现象对应两个完全不同的错误。整体颠倒,是 Canvas 镜像方式写错了,比如先 scale 再 translate,坐标变换顺序不对,纸背就翻了个面;方向对但内容错,是数据源映射错了——直接用当前页 Bitmap 做镜像,而没有为每张纸维护独立的反面纹理。
解决:镜像顺序写成先 translate 到页面宽度位置,再 scale(-1, 1),不要反过来,这个顺序在第三章的代码里有展示。数据源的问题要从业务层解决:给每张纸定义 front 和 back 两个纹理,翻页时纸背层永远绘制 back,而不是「当前页纹理翻转」。如果产品需求里确实不要求显示真实背面内容,宁可做一张和原页面对称但带纹理差异的占位背页,也不要直接把正页镜像硬顶上去,用户能看出来。
5.3 双开内存暴涨:一页变两页的开销失控
现象:单页翻页内存平稳,切到双开模式后,翻几页内存就上涨几十 MB,在低端机上翻不到十页就出现卡顿甚至闪退。
原因:双开模式天然持有两张页面的纹理,再加上纸背,同一时间存在三张 Bitmap。如果预加载从一页变成三页,而且纹理都按逻辑大尺寸生成,内存失控几乎是必然。另一个隐性来源是翻页结束后没有及时释放不再可见的页面纹理,池子越堆越大。
解决:先按第四章的表格核算当前方案的纹理尺寸,把不需要阿尔法通道的页面全部切到 RGB_565。预加载策略改为「只预加载下一页」,不要因为双开就同时预加载左右两页。翻页完成回弹后主动回收屏幕上看不到的纸背纹理,让池子里最多保留三张活跃纹理。如果页面内容是长文章,不要整页生成 Bitmap,按显示区域分块生成,配合滚动加载。
5.4 快速连翻动画错乱:状态机缺了中间态
现象:用户快速连续翻页时,页面出现「翻到一半又跳回起点」「两次翻页叠在一起」的视觉错乱,有时动画结束后显示的页数和手势预期不一致。
原因:触摸事件和动画动画互相抢占状态。ACTION_UP 触发翻页动画后,用户马上进行下一次触摸,代码直接重置 progress,导致动画从 0 重新开始,看起来就像页面弹了回去;或者上一次动画还没结束,下一次动画又创建了一个 Animator,两个动画同时改写同一个 state,画面在中间态之间反复横跳。
解决:把状态拆成两种——触摸态和动画态。触摸态由手指控制,动画态由 Animator 控制,两者互斥。进行中的动画态没有结束前,动画回调里任何 draw 都基于「当前中间值」继续走,而不是回到起始值再翻一遍。我在实现里用 Animator 作为唯一状态源:每次 up 时取消旧的 Animator,从 state 当前值创建一个新 Animator,目标值是翻完或回弹,这样连翻时动画的衔接是连续的,不会跳变。这条坎过了之后,连翻体验才谈得上稳定。
5.5 低端机触摸卡顿:主线程全量重绘是元凶
现象:中高端机上翻页流畅,换到低端机后手指拖动时页面跟不上,动画中间明显掉帧,触摸响应延迟能有半秒。
原因:双开翻页的每次 invalidate 都触发整个 View 重绘,三张纹理加阴影的绘制量集中在主线程。低端机 CPU 单核性能弱,decode、drawBitmap、clipPath 全部挤在同一帧里,超出了 16ms 的预算,触摸自然就卡了。
解决:先从绘制量下手,把阴影层缓存成一张 Bitmap,阴影不随 progress 变化的部分不要每帧重新 drawPath;三张纹理里,纸背纹理只在翻页期间显示,翻页结束后立即释放,减轻后续帧的绘制压力。其次把纹理生成全部移到子线程,主线程只做 Canvas 绘制,Bitmap 创建和像素拷贝不要出现在 onDraw 路径里。最后是脏矩形优化,左右两页中只有正在翻的那一侧需要 invalidate,静止的另一侧不要重绘;如果页面更新只发生在某一块区域,用 invalidate(left, top, right, bottom) 代替无参 invalidate,四参数版本能显著减少低端机的无用绘制。
6. 验收双开翻书效果:帧耗时、跟手性与三个交互细节
功能跑通之后,真正的验证才刚开始。打开开发者选项里的「GPU 渲染模式分析」,把图表切到条形图模式,翻页过程中每一帧的耗时应该稳定在 16ms 以下,偶尔有少量超过不致命,但如果翻页期间连续出现远高于 16ms 的柱形条,就要回到第 5 章逐条排查。不要只看真机流畅度,不同机型的渲染能力差异很大,帧耗时数据比主观感受可靠得多。
跟手性的验证方法很简单:用手指数次快速划动,观察翻起的页角是否始终贴着手指位置。进度落后超过一帧,用户会感觉到「纸没粘在手上」,通常是主线程绘制过重或纹理生成同步执行导致的。这里有一个很实用的技巧,在 ACTION_MOVE 里把 progress 计算改为基于当前事件时间戳插值,而不是直接把手指位移硬映射到 progress,可以让触摸响应更平滑,但插值系数不要超过 0.3,否则又会变得不跟手。
三个交互细节值得在验收时单独过一遍。第一个是双开中缝的处理,纸张翻到中缝附近时,应该有一条明确的暗部过渡,没有中缝暗部的双开会让人分不清左右页的边界;第二个是翻页动画结束落定那一刻,页面要有一两帧的「吸附感」,可以在翻完插值器上加一个非常小的 overshoot,让纸有一种轻轻落定的感觉;第三个是翻页过程中禁用页面内点击,否则用户会误触正在变换位置的文本或图片。
我之前在某图像处理 Demo 上做双开时,把大量时间花在调整贝塞尔弧度和阴影颜色上,后来才发现真正决定体验的其实是触摸映射和纹理分辨率两件事。弧线再漂亮,帧率不稳定、手指不跟手,用户也不会觉得这是「像一本书」的体验。先保证状态机干净、纹理尺寸合理,再去打磨视觉细节,这个顺序能省下不少返工时间。希望这篇笔记能帮你在做双开翻书效果时少踩几个坑。
本文还有配套的精品资源,点击获取