首页 / 资讯中心 / 文章详情

HanziWriter小程序适配实践:Canvas重绘汉字笔顺动画

HanziWriter小程序适配实践:Canvas重绘汉字笔顺动画 ★ FEATURED ARTICLE
1. 为什么直接拿 HanziWriter 跑小程序会“翻车”1.1 HanziWriter 到底在浏览器里做了什么HanziWriter 是我见过的比较顺手的汉字笔画动画开源库在浏览器端集成特别快几行代码就能把一个汉字的笔顺动画、描红练习做得像模像样。它的底层核心其实就两块一部分是汉字笔画数据另一部分是渲染器。笔画数据描述了一个字由哪些笔画组成、每一笔的运笔路径是什么渲染器则把这些数据变成屏幕上看得见的线条和动画。具体到实现上HanziWriter 在浏览器里默认使用 SVG 进行渲染。SVG 是 DOM 树的一部分每个笔画对应一个path元素动画时通过不断修改路径的stroke-dasharray或者逐段追加路径点来实现笔顺效果。因为 SVG 元素天然支持 CSS 样式、事件绑定所以做描红交互时HanziWriter 可以直接监听鼠标或触摸事件再根据命中的笔画做反馈。这些能力加起来在 Web 页面上确实没什么毛病开箱即用。1.2 小程序端和浏览器到底差在哪问题就出在“浏览器”这三个字上。微信小程序虽然也内置了一套运行环境但它跟浏览器是两个世界。最核心的差异有三个第一小程序没有 DOM 树。你不能动态创建svg、path这类节点更谈不上给它们绑定事件。HanziWriter 的 SVG 渲染层在小程序里根本跑不起来。第二小程序的 Canvas 是一套独立的组件体系。旧版 Canvas 需要通过wx.createCanvasContext拿到绘图上下文新版 Canvas 2D 接口接近浏览器标准但也不是 100% 一致。HanziWriter 官方的渲染模块没有针对小程序 Canvas 做适配你没法直接 new 一个实例来用。第三网络和字体环境受限。浏览器里可以任意加载 woff/ttf 字体文件小程序加载字体要走wx.loadFontFace而且对字体文件大小、格式还有平台限制。中文字体动不动就几 MB在小程序端如果不处理好渲染出来的笔画可能全是方块或者直接不显示。我自己第一次接到“把 HanziWriter 搬到小程序端”的需求时也试过找现成的小程序插件结果一圈搜下来发现基本没有维护良好、能直接用 HanziWriter 的项目。所以最终还是要自己动手把 HanziWriter 的“数据能力”剥离出来再用小程序的 Canvas 重新实现渲染层。这个思路本身并不复杂真正花时间的是各种细节——接下来我按实际开发顺序把这些坑一个个说清楚。2. 适配方案的选型别一上来就埋头改代码2.1 方案 AWebView 套壳省事但坑不少既然小程序端跑不了 DOM有同学第一时间想到的是能不能在小程序里嵌一个 WebView把跑着 HanziWriter 的 H5 页面放进去这个方案确实省事H5 那边怎么写WebView 里原样跑几乎不用改代码。但实际用下来会有几个麻烦。WebView 在小程序里是一个独立 WebView 组件它跟小程序原生层之间是隔离的你不能直接调用小程序的登录态、支付、云开发等能力除非通过postMessage这种桥接方式来回通信繁琐。更重要的是WebView 的加载速度和渲染性能在低端安卓机上有明显卡顿汉字笔顺动画本身对帧率敏感一旦掉帧用户体验非常糟糕。此外小程序审核对 WebView 嵌套 H5 也有一定限制如果 H5 内容跟小程序主体业务不一致很容易被驳回。所以我的判断是如果你的需求只是“临时展示一个笔顺动画”WebView 可以凑合但如果你要做的是“可交互、可练习、有积分体系”的汉字学习功能WebView 方案迟早会因为性能或交互问题重写。2.2 方案 B抽取数据用 Canvas 重绘我最终选这条路我最终选择的是这条路保留 HanziWriter 的字符数据处理逻辑抛弃它的 SVG 渲染层在小程序 Canvas 上重新实现一套绘制与交互。为什么可行因为 HanziWriter 的字符数据本质上就是一组坐标点集合。每个汉字的每一笔都被抽象成一条由多个折线点组成的路径数据格式是标准的 JSON。拿这部分数据直接交给小程序的 Canvas API逐点画线、逐笔播放完全可以实现和浏览器端几乎一样的效果。这里面省下来的工作量非常可观字形数据不用自己整理汉字的笔画拓扑、笔顺规则是很复杂的东西渲染逻辑只需要处理 Canvas 的moveTo、lineTo、stroke这几个基础方法交互逻辑也只是对触摸坐标做点判断。HanziWriter 里最难的“汉字数据”已经被解决掉了我做的是用另一个渲染器去消费这些数据。2.3 三个方案怎么选给你个参考标准其实除了 WebView 和 Canvas 重绘还有第三个思路完全不用 HanziWriter自己整理笔画数据 自研 Canvas 渲染。这个方案适合对数据有特殊要求的项目比如你想在笔画数据上追加“笔锋”“轻重”这类书写质感HanziWriter 的折线数据就不够了得自己重新采集或加工。但这也意味着工作量大幅上升而且汉字数量一多数据获取就成了大坑。给个简单的选型参考项目周期紧、只需要展示动画、不涉及深度交互 → WebView 套壳快。需要稳定交互、性能要求高、数据量大 → Canvas 重绘推荐。有特殊字形需求、完全定制笔迹风格 → 自研数据与渲染一步到位。我当时评估下来Canvas 重绘是性价比最高的既能继承 HanziWriter 的海量数据又能获得接近原生的性能和交互体验。后面的内容也全部围绕这条路线展开。3. 基于 Canvas 的 HanziWriter 小程序端实现细节3.1 拿到 HanziWriter 的字符数据是关键HanziWriter 的字符数据可以从 npm 包内部获取。它本质上是一个对象结构核心字段是medians和strokes。我举个例子简化后的“一”字数据大致长这样{ strokes: [1], medians: [ [[27, 91], [41, 98], [124, 104], [170, 101], [194, 92]] ] }strokes数组表示这个字的笔画列表每个元素是个笔画 ID是一些内部标记符。medians数组对应每条笔画的“骨架路径”每个元素是一组[x, y]坐标点。这些坐标是相对值范围通常在 0 到 204 之间。在浏览器端HanziWriter 会把这些相对坐标按目标尺寸缩放再映射到 SVG 坐标系里。我在小程序端要做的事情也一样读取medians把坐标按画布实际尺寸等比例放大再用 Canvas API 逐点绘制。获取数据的方式有两种我以微信小程序为例// 方式一从 npm 包中直接获取 const hanziWriter require(hanzi-writer); const charData hanziWriter.getCharacterData(汉); console.log(charData.medians);// 方式二离线数据 JSON推荐 // 把常用汉字的 medians 数据打包成 JSON 文件放本地 // 运行时直接从文件读取避免依赖库本身的初始化逻辑。理论上 HanziWriter 的 npm 包是可以被小程序构建工具打包的但为了减小包体、提高加载速度我建议把常用字的数据抽出来单独存储。比如做一个“教材同步生字表”功能时只需要打包那几百个汉字的数据而不是整个 HanziWriter 的完整字库。3.2 笔画动画和描红交互的实现拿到medians之后核心工作就两个画一条笔画、按进度播放。先看单笔绘制的实现。假设我拿到一条笔画路径[[x0,y0], [x1,y1], ...]在 Canvas 上画出来就是function drawStroke(ctx, points, scale, offsetX, offsetY) { ctx.beginPath(); ctx.moveTo(points[0][0] * scale offsetX, points[0][1] * scale offsetY); for (let i 1; i points.length; i) { ctx.lineTo(points[i][0] * scale offsetX, points[i][1] * scale offsetY); } ctx.stroke(); }这里scale是坐标放大倍数offsetX和offsetY是把汉字居中到 Canvas 里的偏移量。HanziWriter 的原始数据坐标系是固定的你需要根据自己的 Canvas 尺寸计算缩放值。然后看笔顺动画。笔顺动画的本质是按时间依次显示每一条笔画并且每条笔画内部也是从起点开始逐步画到终点。HanziWriter 在浏览器里通过对 SVGpath做“描边”动画实现在小程序 Canvas 里我采用“裁剪 逐段绘制”的方式先创建一块离屏 Canvas把当前笔画的完整路径画上去。根据播放进度计算需要显示到哪个坐标点。用ctx.clip()配合一个矩形或者自定义路径只绘制到当前进度对应的部分。简化实现的思路如下function animateStroke(ctx, points, progress) { ctx.clearRect(0, 0, canvasWidth, canvasHeight); ctx.save(); ctx.beginPath(); ctx.rect(0, 0, clipX, clipY); ctx.clip(); drawStroke(ctx, points, scale, offsetX, offsetY); ctx.restore(); }具体到“当前看见多少”可以用progress乘以笔画路径的总长度算出当前应该显露到哪个坐标点。如果追求简单也可以直接用坐标点的数量做近似比如总共有 20 个点进度 50% 就只画前 10 个点。缺点是笔画长的时候可能会有点“跳段”感但对大多数汉字来说点足够密肉眼几乎察觉不到。描红交互的部分核心是判断用户按下的位置是否在当前应该书写的笔画附近。我的做法是在 Canvas 的touchstart、touchmove、touchend事件中通过e.touches[0].x和e.touches[0].y拿到触摸点。遍历当前笔画的坐标点找出与触摸点距离最近的那一个如果距离小于一定阈值比如 15 像素就认为用户在正确的笔画区域内。当用户落笔后每移动一个点就把对应坐标段的前半段画成用户笔迹颜色后半段保留灰色底形成“跟着写”的效果。这里有个坑旧版 Canvas 的触摸坐标和绘图坐标的坐标系不同需要做一次转换。e.touches[0].x拿到的是相对页面的坐标绘制时用到的是 Canvas 内部坐标。通常做法是拿 Canvas 的 boundingClientRect 做差值const query wx.createSelectorQuery(); query.select(#canvas-id).boundingClientRect(rect { const touchX e.touches[0].clientX - rect.left; const touchY e.touches[0].clientY - rect.top; }).exec();这个问题特别容易出现在 iOS 设备上因为页面可能有滚动或缩放坐标偏移量不一样。建议封装一个统一的坐标转换函数所有触摸事件都走这个函数。3.3 字体和 Canvas 适配要处理好的几个点汉字在 Canvas 上的视觉呈现除了笔画路径还有一个容易被忽略的点描红时通常会显示一个灰色的“底字”这个底字如果直接用字库渲染会因为字体文件缺失而显示乱码或方块。我的解决方案是底字不用系统字体渲染而是同样用 HanziWriter 的medians数据来画。把完整笔画用灰色、较粗的线宽画一遍就是一个标准的描红底模。这样不需要任何字体文件也不会出现跨平台乱码的问题而且底模和手写笔迹在坐标系上天然对齐不会出现“底模是一个位置用户写出来是另一个位置”的偏差。如果你的产品经理坚持要“楷体底模”这样的效果那就绕不开字体加载了。微信小程序提供wx.loadFontFace可以加载网络字体但要注意字体文件格式建议用 TTF 或 WOFFiOS 支持还好Android 部分机型对 WOFF/WOFF2 兼容性不行比较稳妥的是 TTF。字体文件别太大超过 2MB 的字体在弱网下加载时间很长体验很受影响。loadFontFace加载是全局生效的最好在页面初始化时提前调用并且在onLoad里做失败重试否则后续 Canvas 重绘可能因为字体没加载好而出现显示异常。Canvas 本身也需要注意devicePixelRatio的问题。如果直接用 CSS 尺寸设置 Canvas在 Retina 屏上绘制出来的线条会发虚。我的做法是const dpr wx.getWindowInfo().pixelRatio; const canvasWidth 300; const canvasHeight 300; canvas.width canvasWidth * dpr; canvas.height canvasHeight * dpr; ctx.scale(dpr, dpr);这样画布物理像素和 CSS 像素才能对齐笔画线条才清晰。4. 性能优化与多端兼容性实测4.1 动画帧率和 Canvas 重绘性能HanziWriter 在浏览器端做笔顺动画时浏览器渲染引擎有大量优化比如跳过不可见区域、GPU 合成等。小程序 Canvas 没有这么完备的优化尤其旧版 Canvas 接口性能确实有限所以我们在代码层面需要做一些取舍。我实测下来第一个要注意的点是动画过程中尽量减少clearRect的调用范围。假如 Canvas 大小是 300x300完整的clearRect(0, 0, 300, 300)在低端安卓机上每帧都会造成全屏重绘耗时明显。如果笔画动画只出现在画布中央可以只清空笔画区域或者用离屏 Canvas 预先绘制静态底模动画帧只叠加绘制动态笔画部分。第二个点是用requestAnimationFrame控制帧率。小程序 Canvas 的requestAnimationFrame虽然在自定组件里可以用但有些基础库版本对它的支持不算稳定。我一般会自己做一个简单的帧控函数function raf(callback) { if (typeof requestAnimationFrame function) { return requestAnimationFrame(callback); } return setTimeout(() callback(Date.now()), 16); }这样即使在比较旧的运行环境里也可以把动画控制在 60 FPS 附近如果机器性能差可以主动降帧比如每两帧更新一次人眼其实分辨不太出来。第三个点是笔画数据的预计算。在动画开始前把每条笔画的坐标点、总长度、各段累计长度都提前算好存下来不要在动画循环里反复做开平方、数组遍历之类的高成本操作。笔顺动画的耗时瓶颈往往不是 Canvas 绘制本身而是每帧都在重复计算坐标。4.2 不同机型和平台的实际表现兼容性上我的测试结论是这样的iOS 设备整体表现最好。Canvas 的渲染性能和触摸事件响应都很稳定pixelRatio适配做好之后线条边缘清晰动画流畅内存占用也不会突然飙升。即使是最低端的 iPhone SE 一代跑几个常用汉字的动画也没有明显问题。Android 则是分化严重。旗舰机比如骁龙 8 系列机型表现跟 iOS 差距不大但中低端机尤其是老旧的千元机在笔画较密的汉字比如“疆”“翼”这样笔画多的字动画过程中会明显感觉到帧率下降。我遇到过一次比较极端的 Case一个 3 年前的低端 Android 手机上连续播放“龘”字动画时直接出现 Canvas 绘制错乱笔画出现残影。排查后发现是动画帧中ctx.save()和ctx.restore()没成对出现导致裁剪状态残留引发后续绘制异常。所以在代码里我格外注意所有ctx.save()必须有对应的ctx.restore()裁剪和缩放操作一定要包裹在二者之间。这一点在 iOS 上偶尔出问题也不明显在 Android 上就非常容易暴露。还有一个容易被忽略的兼容性问题是小程序基础库版本差异。同一个wx.createCanvasContext接口在老版本基础库上可能没有问题在新版本上由于 Canvas 组件底层渲染机制调整可能会出现坐标偏移。我的做法是在关键绘制逻辑里加一个“渲染模式”开关根据wx.getSystemInfoSync().SDKVersion选择不同的坐标系计算方式。虽然多加了一些分支但总比用户反馈“画不准”再重新发版要好。5. 小程序端 HanziWriter 高频问题排查5.1 问题速查表我在开发过程中整理了一个问题速查表基本上遇到过的坑都列在这里了现象可能原因解决办法汉字完全不显示medians数据没有正确加载或 Canvas 绘制坐标计算错误打印medians数据确认 scale 和 offset 计算是否正确笔画位置偏左/偏右坐标未按 Canvas 实际尺寸缩放或 dpr 适配错误用canvas.width / 204计算缩放比并检查 dpr 处理动画只显示第一笔动画循环里没有更新“当前笔画索引”检查是否在结束上一笔后正确递增索引并触发下一笔绘制触摸点与笔画位置对不上触摸坐标没有做 Canvas 坐标转换用 boundingClientRect 计算相对坐标并做 dpr 换算描红写入时笔画抖动touchmove 事件频率过高绘制状态混乱增加事件节流比如 16ms 一次并在每次 touchstart 时重置状态Android 低端机卡顿Canvas 全屏重绘 数据实时计算采用离屏 Canvas 预绘制底模限制动画帧率预计算坐标字体加载后底字仍是方块使用了系统字体渲染底字改用 medians 数据画灰色底模或确认 loadFontFace 加载成功5.2 排查思路补充如果遇到速查表里没有覆盖的情况我一般按下面这个顺序排查第一步确认数据层没问题。把medians打印出来手工挑几个坐标点算一下看看缩放后是不是落在 Canvas 尺寸范围内。很多绘图异常其实是坐标映射错误而不是 Canvas 接口用错了。第二步用最简单的绘制验证 Canvas 环境。写一段只会一条直线的代码如果直线都画不出来说明 Canvas 上下文获取或尺寸设置有问题先解决这个。第三步分步启用功能。先实现单笔画静态绘制再实现单笔画动画最后做多笔画串联和描红交互。每一步都确认效果正常后再继续千万不要一次性把全部功能写完再调试那样出问题很难定位。第四步真机测试 真机调试。小程序开发者工具的 Canvas 渲染跟真机不完全一致很多坐标和性能问题只有在真机上才能复现。至少准备一台 iPhone、一台主流 Android、一台低端 Android。6. 顺带聊聊uni-app 的 App 端拉起微信小程序的联动玩法6.1 拉起小程序的开发准备很多汉字学习类产品的用户其实是从 App 端进入的但笔画练习这类轻交互功能放在小程序里更合适。于是项目里就会有一个很典型的跨端需求用户正在用 App想把用户引导到微信小程序继续体验某个功能。在 uni-app 项目里这个需求可以通过uni.openEmbeddedMiniProgram来实现它的底层对应的是微信的wx.openEmbeddedMiniProgram接口。这个接口允许 App 端在用户授权后直接拉起一个指定 appId 的微信小程序跳转到对应页面并可以携带一些参数。基本用法是这样的uni.openEmbeddedMiniProgram({ appId: 你的小程序AppID, path: pages/lesson/hanzi?char汉, extraData: { from: app, token: xxx }, success: (res) { console.log(拉起小程序成功, res); }, fail: (err) { console.log(拉起小程序失败, err); } });这里有几个硬性条件你的 App 必须是微信开放平台注册过的应用绑定了小程序 AppID用户在 App 内要完成微信授权登录拉起小程序的操作必须由用户主动触发不能自动无声跳转。6.2 在实际汉字学习场景中这么玩结合 HanziWriter 汉字笔画练习来看这个联动很自然App 端放了完整的学习课程但具体到一个汉字的笔顺动画可以在小程序端做沉浸式体验。App 内用户点击“开始练习这个字”就调用上面的拉起接口跳到小程序对应页面然后小程序根据extraData里的参数定位到具体的汉字直接开始笔顺动画和描红练习。这样设计有几个好处。小程序端不需要维护那么多课程数据聚焦在单个字的交互体验上包体小、启动快App 端也不需要为了一个动画功能引入庞大的汉字数据和 Canvas 绘制逻辑可以保持主包精简用户数据部分通过extraData传递可以打通学习进度。但要注意小程序被拉起后它的onLoad和onShow生命周期跟正常打开小程序不完全一样。我踩过的坑是onLoad参数在部分安卓机型上拿不到完整的数据尤其是path里带中文或特殊符号时容易编码异常。解决方法是页面参数用encodeURIComponent编码小程序侧再用decodeURIComponent解码同时把业务数据放到extraData里而不是只依赖path。还有一点提醒微信对 App 拉起小程序是有限流和权限校验的如果用户没有安装微信或者未授权接口会直接失败。开发时一定要做好fail分支的兜底比如弹窗提示用户“请先安装微信”或者“微信授权失败请稍后重试”。最后再分享一个小技巧。HanziWriter 的medians坐标原点和比例在不同汉字之间是统一的你可以把它理解成一套标准坐标系。我在做多个字连续练习的时候就提前把一批汉字的坐标数据批量转成自己的 JSON 格式用Promise.all并行读取加载完再进练习页。这样用户滑动切换下一个字的时候基本不需要等待动画能立刻开始体验比临时请求数据好很多。这个细节看似不起眼但在真正的教学场景里连续学习十几个生字的频率下等待时间会被放大得非常明显。
阅读完成 · 觉得有帮助?
咨询建站