☰
HanziWriter小程序适配实践:Canvas重绘汉字笔顺动画
2026/10/2 1:47:16 网站建设 项目流程

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 方案 A:WebView 套壳,省事但坑不少

既然小程序端跑不了 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 在浏览器里通过对 SVG<path>做“描边”动画实现,在小程序 Canvas 里,我采用“裁剪 + 逐段绘制”的方式:

  1. 先创建一块离屏 Canvas,把当前笔画的完整路径画上去。
  2. 根据播放进度计算需要显示到哪个坐标点。
  3. 用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 个点。缺点是笔画长的时候可能会有点“跳段”感,但对大多数汉字来说,点足够密,肉眼几乎察觉不到。

描红交互的部分,核心是判断用户按下的位置是否在当前应该书写的笔画附近。我的做法是:

  1. 在 Canvas 的touchstart、touchmove、touchend事件中,通过e.touches[0].x和e.touches[0].y拿到触摸点。
  2. 遍历当前笔画的坐标点,找出与触摸点距离最近的那一个,如果距离小于一定阈值(比如 15 像素),就认为用户在正确的笔画区域内。
  3. 当用户落笔后,每移动一个点,就把对应坐标段的前半段画成用户笔迹颜色,后半段保留灰色底,形成“跟着写”的效果。

这里有个坑:旧版 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 或 WOFF,iOS 支持还好,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并行读取,加载完再进练习页。这样用户滑动切换下一个字的时候基本不需要等待,动画能立刻开始,体验比临时请求数据好很多。这个细节看似不起眼,但在真正的教学场景里,连续学习十几个生字的频率下,等待时间会被放大得非常明显。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询