前阵子接了个佳明手表的表盘需求,要在表盘上显示“今日步数”“昨日里程”这样的中文标签。我最初的想法很简单:MonkeyC里不是有dc.drawText()嘛,直接把中文字符串丢进去不就完了。结果真机一跑,屏幕上全是方块。查了一圈资料才发现,佳明的Connect IQ默认字体只覆盖西文字符集,根本不带汉字。要在佳明手表上显示汉字,得自己动手“喂”给系统。这篇就把我从零到一用MonkeyC实现汉字显示的完整过程写下来,包括方案选型、点阵字库生成脚本、可直接跑通的完整代码,还有我真机调试时踩过的几个坑。适合接佳明表盘/小工具开发、想在手表上显示中文内容的朋友参考。
1. 为什么在佳明手表上显示汉字这么麻烦
1.1 MonkeyC的定位与限制
MonkeyC是佳明为Connect IQ平台专门设计的一门编程语言,语法风格和JavaScript很像,也融合了一点Dart的影子。它能访问的API集合由Toybox系列模块提供,比如Toybox.Graphics、Toybox.WatchUi、Toybox.System等。问题是,这套API的设计初衷是让开发者低成本地做表盘、数据字段和小工具,而不是做一个完整的通用操作系统平台,所以在很多底层能力上做了大幅简化。
比如MonkeyC的String类型就是普通的字节串,没有对Unicode做特别处理。佳明自带的字体资源只包含拉丁字母、数字和常用符号,你没看错,连全角标点都没有。所以当你写出dc.drawText(x, y, font, "今日步数", ...)时,系统根本找不到对应字形,只能渲染成一个一个的空方块。
1.2 手表硬件资源的硬约束
可能有人会问:那能不能直接把一套中文字体文件塞进去?理论可以,现实很骨感。一个完整的中文字体TTF动辄3到8MB,即使是子集化后的常用3500字,也常常要几百KB。而很多佳明手表的应用区内存只有几十KB到一两百KB,图标资源、图片资源还要占用一部分。你在模拟器上跑得好好的,一上真机就内存溢出闪退,这是新手最容易翻车的地方。
所以,在佳明手表上显示汉字,本质是一个在“极小的内存预算里嵌入中文字形数据”的工程问题。理解了这条,你就能明白为什么大家最终都会走向两条路:要么把字体文件压缩到极限,要么干脆放弃字体文件,直接用点阵字模逐像素画。
1.3 先搞清楚你要显示什么内容
做汉字显示之前,先问自己一个问题:你需要显示的汉字是固定的,还是会动态变化的?
- 固定的标签文字,比如“心率”“步数”“距离”,数量不超过几十个,用点阵字模最合适,速度快、不占内存。
- 需要动态拼装的中文句子,比如“早上好”“继续加油”,数量不确定,但总数可控,可以用“字体子集化+自定义字体”。
- 完全随机的用户输入,比如联网获取的推送内容,那基本不建议在表盘里做,内存和性能都扛不住。
我这次的需求属于第一类:表盘上固定的几个中文标签。所以我最终采用的是16x16点阵字模方案,这也是本文的重点。当然,自定义字体子集化方案我也会在下面详细讲,它更适合文本较多的场景。
2. 三种可行的汉字显示方案对比
2.1 方案一:自定义字体资源(字体子集化)
Connect IQ是支持自定义字体的,你可以在resources.xml里声明一个TTF字体文件,然后在代码里用WatchUi.loadResource()加载。关键在于,你不能把整个中文字体文件扔进去,必须先把字体做子集化,只保留用得到的汉字。
我试过用pyftsubset工具来做:
pyftsubset SourceHanSansSC-Regular.otf \ --text="今日步数心率先锋里程距离你好世界加油" \ --output-file=my_subset.ttf \ --no-hinting \ --desubroutinize这条命令会从思源黑体里抽取指定字符串包含的所有汉字生成一个新字体文件。处理完通常能压到20KB到80KB,看你要的字数多少。然后在工程里这样声明:
<resources> <font id="CnFont" antialias="false" filename="my_subset.ttf" /> </resources>代码里这样用:
using Toybox.WatchUi; using Toybox.Graphics; var cnFont = WatchUi.loadResource(Rez.Fonts.CnFont); dc.setFont(cnFont); dc.drawText(10, 10, cnFont, "今日步数", Graphics.TEXT_JUSTIFY_LEFT);这个方案的好处很明显:可以复用系统的文本绘制能力,支持任意组合的字符串,抗锯齿关掉之后在小屏上显示还特别锐利。缺点也明显:即使子集化,字体文件仍然会占用一定空间;在低端设备上加载时会有几十到几百毫秒的卡顿;如果后续要新增没包含在子集里的汉字,你得重新跑一遍子集化流程。
2.2 方案二:16x16点阵字模逐像素绘制
这正是本文要展开讲的核心方案。思路和单片机驱动OLED显示屏时显示汉字一模一样:把每个汉字变成一个16x16的点阵,也就是32字节的位图数据,然后通过dc.setPixel()逐点画出来。
这种方案有几个难以拒绝的优势:
- 不引入任何外部字体文件,字模数据直接写在代码里,加载速度极快。
- 内存占用可以精确计算,一个字32字节,10个字才320字节。
- 不依赖系统字体渲染引擎,显示效果完全由你自己的数据决定,可控性极强。
- 对老设备兼容性最好,哪怕是很早期的Connect IQ版本也能跑。
缺点也诚实说一下:代码量会多一点,而且字模数据得自己准备。不过这个问题用脚本可以彻底解决。
2.3 方案三:预渲染成PNG图片
如果你只是想在表盘上放几个固定的中文汉字,那还有一个笨办法:用PS或在线工具把汉字渲染成透明底PNG,放到resources/drawables/目录下,然后通过dc.drawBitmap()绘制。
dc.drawBitmap(10, 10, WatchUi.loadResource(Rez.Drawables.hello));这个方案实现起来是最简单的,适合完全不懂代码只想做表盘的人。但问题也很严重:每个字都是一张图,想调整位置、颜色、大小都得重新导出;不能动态拼接文本;图片解码也要占额外内存。所以但凡你有一点编程基础,我都不推荐把这条路作为主要方案。
2.4 方案对比
| 方案 | 内存占用 | 实现难度 | 动态文本支持 | 推荐场景 |
|---|---|---|---|---|
| 字体子集化 | 中(几十KB) | 中 | 好 | 文字较多且基本固定的表盘 |
| 点阵字模 | 极低(几KB) | 较高 | 一般,需按字索引 | 小内存表盘、固定标签 |
| 预渲染图片 | 中 | 低 | 差 | 简单静态显示 |
我这次做的表盘有6个固定中文标签,用点阵字模方案,最终整个应用体积比用字体子集化方案小了接近400KB,上真机后启动速度也明显快了不少。
3. 完整实现:用Python生成汉字点阵数据
3.1 准备工具和字体
在写MonkeyC代码之前,先把字模数据准备好。我这里用Python加Pillow库来生成点阵数据,脚本只有几十行。
需要准备的东西:
- Python 3环境,安装Pillow库:
pip install pillow - 一个中文字体文件。推荐“文泉驿点阵宋体”或者“思源黑体”的TTF/OTF,开源免费。文泉驿点阵宋体在16px尺寸下观感最好,因为它本身就是点阵优化过的。
3.2 Python脚本:把汉字转成16x16点阵
下面这个脚本会读取一个字体文件,把目标汉字逐字转换成16x16的点阵字节流,并输出MonkeyC可以直接粘贴的数组格式。
# -*- coding: utf-8 -*- from PIL import Image, ImageDraw, ImageFont def char_to_bitmap_16x16(char, font_path): # 加载字体,16号 font = ImageFont.truetype(font_path, 16) # 创建16x16的1bit画布 img = Image.new("1", (16, 16), 0) draw = ImageDraw.Draw(img) # 计算绘制位置,让字形居中 # 部分字体的字形会稍微偏左,这里手动微调偏移 draw.text((-1, -1), char, font=font, fill=1) # 按行扫描,每行两个字节,共16行 data = [] for row in range(16): byte_left = 0 byte_right = 0 for col in range(8): pixel = img.getpixel((col, row)) if pixel > 0: byte_left |= (1 << (7 - col)) for col in range(8, 16): pixel = img.getpixel((col, row)) if pixel > 0: byte_right |= (1 << (15 - col)) data.append(byte_left) data.append(byte_right) return data def format_as_monkeyc(data, indent=" "): parts = [] for i in range(0, len(data), 12): chunk = data[i:i+12] line = indent + ", ".join(f"0x{v:02X}" for v in chunk) + "," parts.append(line) return "\n".join(parts) if __name__ == "__main__": font_path = "wqy-microhei.ttc" # 换成你的字体文件路径 text = "你好世界今日步数" print("var glyphs = [") for ch in text: data = char_to_bitmap_16x16(ch, font_path) print(" [") print(format_as_monkeyc(data)) print(" ],") print("];")运行后会输出类似下面这样的结构:
var glyphs = [ [ 0x00, 0x00, 0x00, 0x04, 0x00, 0x64, 0x00, 0x44, 0x02, 0x44, 0x02, 0x44, 0x0F, 0xC4, 0x02, 0x44, 0x02, 0x44, 0x02, 0x44, 0x02, 0x44, 0x04, 0x44, 0x08, 0x44, 0x30, 0x44, 0xC0, 0x44, 0x00, 0x40, ], [ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, 0xF8, 0x00, 0x00, 0x01, 0x00, 0x21, 0x00, 0x21, 0x00, 0x21, 0x00, 0x3F, 0x00, 0x21, 0x00, 0x21, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, ], ... ];注意,上面这段数据是跑出来给大家演示格式用的,不同字体渲染出来的点阵数据会不一样,不要直接拿到项目里当“你、好”的准确字模用。一定要自己跑一遍脚本。
3.3 字模格式说明
我这里采用的字模排列方式是逐行排列,每行16个点拆成左右两个字节,左边字节对应第0到7列,右边字节对应第8到15列,每个字节高位在左。这是一个非常常见的排列方式,和很多OLED取模软件的“横向取模、逐行式”一致。
数据量和分辨率的换算很简单:16x16点阵,每行2字节,16行,总共32字节。如果你要显示24x24的字,那么每行3字节,24行,总计72字节。显示尺寸越大,字形越清晰,但绘制消耗的CPU周期也越多,在低端手表上建议先用16x16跑通,再考虑升级。
3.4 一个小提醒:注意字体版权
做字库子集化也好,点阵取模也好,记得用开源可商用的字体。思源黑体、思源宋体、文泉驿系列都是开源友好的选择,商用没问题。不要随便拿商业字体来取模嵌入到你的付费表盘里,这个是有版权风险的。
4. MonkeyC工程搭建与核心绘制代码
4.1 创建Connect IQ工程
用Visual Studio Code的Connect IQ插件或者Eclipse插件新建一个Watch App工程。如果你用命令行工具,也可以先用monkeyc创建一个骨架工程。
工程目录结构大概是这样的:
project/ manifest.xml monkey.jungle resources/ resources.xml source/ App.mc View.mc bin/monkey.jungle里引入SDK和工程源文件:
// monkey.jungle using "source/App.mc"; using "source/View.mc";4.2 App入口代码
using Toybox.Application; class HanZiDemoApp extends Application.AppBase { function initialize() { AppBase.initialize(); } function onStart(state) { } function onStop(state) { } function getInitialView() { return [ new HanZiDemoView() ]; } }这段代码很常规,就是Connect IQ应用的入口。getInitialView()返回一个View数组,第一个是主View。
4.3 View里的核心绘制代码
下面这个View是完整的实现。我把上一节Python脚本生成的字模数组放到了glyphs里,然后在onUpdate里用一个drawGlyph函数把点阵画到屏幕上。
using Toybox.Graphics; using Toybox.WatchUi; using Toybox.Lang; class HanZiDemoView extends WatchUi.View { // 用Python脚本生成的字模数据,这里只保留两个演示,实际可以把所有需要的字全部放进来 var glyphs = [ [ 0x00, 0x00, 0x00, 0x04, 0x00, 0x64, 0x00, 0x44, 0x02, 0x44, 0x02, 0x44, 0x0F, 0xC4, 0x02, 0x44, 0x02, 0x44, 0x02, 0x44, 0x02, 0x44, 0x04, 0x44, 0x08, 0x44, 0x30, 0x44, 0xC0, 0x44, 0x00, 0x40, ], [ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, 0xF8, 0x00, 0x00, 0x01, 0x00, 0x21, 0x00, 0x21, 0x00, 0x21, 0x00, 0x3F, 0x00, 0x21, 0x00, 0x21, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, ] ]; var textLabels = ["你", "好"]; function initialize() { View.initialize(); } function onLayout(dc) { } function onUpdate(dc) { dc.setColor(Graphics.COLOR_BLACK, Graphics.COLOR_WHITE); dc.clear(); var startX = 8; var startY = 24; // 依次绘制两个字模,每个字宽16像素,间距2像素 for (var i = 0; i < glyphs.size(); i++) { drawGlyph(dc, glyphs[i], startX + i * 18, startY, Graphics.COLOR_WHITE); } // 在字模下面绘制普通英文文本,作为对比 dc.setColor(Graphics.COLOR_WHITE, Graphics.COLOR_TRANSPARENT); dc.drawText(startX, startY + 24, Graphics.FONT_SMALL, "Hello", Graphics.TEXT_JUSTIFY_LEFT); } // 逐像素绘制一个16x16字模 function drawGlyph(dc, data, x, y, color) { for (var row = 0; row < 16; row++) { var byteLeft = data[row * 2]; var byteRight = data[row * 2 + 1]; for (var col = 0; col < 8; col++) { var bit = (byteLeft >> (7 - col)) & 0x01; if (bit == 1) { dc.setPixel(x + col, y + row, color); } } for (var col = 0; col < 8; col++) { var bit = (byteRight >> (7 - col)) & 0x01; if (bit == 1) { dc.setPixel(x + 8 + col, y + row, color); } } } } }这段代码的核心就是drawGlyph函数。它的逻辑很直白:从数据的第一个字节开始,每个字节8位,从高位往低位逐位判断,如果这一位是1,就在对应的坐标画一个像素点。因为16列被拆成了左右两个字节,所以先处理byteLeft,再处理byteRight。行号row每处理完两个字节就加一。
4.4 让绘制更快:BufferedBitmap缓存
上面这种逐像素setPixel在模拟器上看起来没问题,但在低端真机上,如果你在表盘上每帧都重新画一次,有可能会感觉到掉帧。优化的办法是:在onLayout里把字模先画到一个离屏Bitmap上,onUpdate里只需要用drawBitmap整体贴出来。
using Toybox.Graphics; using Toybox.WatchUi; var glyphBmp; function onLayout(dc) { // 创建两个字的离屏Bitmap,宽 = 16*2 + 间距,高 = 16 glyphBmp = new Graphics.BufferedBitmap({ :width => 34, :height => 16, :colorDepth => Graphics.COLOR_8BIT_A444 }); var bmpDc = glyphBmp.getDc(); bmpDc.setColor(Graphics.COLOR_TRANSPARENT, Graphics.COLOR_TRANSPARENT); bmpDc.clear(); // 把字模画到离屏Bitmap上 for (var i = 0; i < glyphs.size(); i++) { drawGlyph(bmpDc, glyphs[i], i * 18, 0, Graphics.COLOR_WHITE); } } function onUpdate(dc) { dc.setColor(Graphics.COLOR_BLACK, Graphics.COLOR_WHITE); dc.clear(); // 整体绘制,一次调用远快于几十次setPixel dc.drawBitmap(8, 24, glyphBmp); }这里几个细节要注意:
BufferedBitmap的colorDepth参数不是所有设备都支持COLOR_8BIT_A444,如果编译报错,可以换成Graphics.COLOR_8BIT_INDEXED,透明效果可能会差一点,但对绘制单色字模影响不大。getDc()返回的Dc和普通View的Dc一样,可以在上面调用setPixel、clear等方法。- 离屏Bitmap创建后会占用显存,记得在不需要时释放引用。
4.5 如何把自定义字体方案也跑通
如果你最终决定走“字体子集化+自定义字体”的路线,我在前面已经给了子集化命令和资源声明。代码部分,你需要把Rez.Fonts里定义的字体加载出来:
using Toybox.WatchUi; using Toybox.Graphics; function drawChineseText(dc, x, y, text) { var cnFont = WatchUi.loadResource(Rez.Fonts.CnFont); dc.setFont(cnFont); dc.setColor(Graphics.COLOR_WHITE, Graphics.COLOR_TRANSPARENT); dc.drawText(x, y, cnFont, text, Graphics.TEXT_JUSTIFY_LEFT); }这里有个容易被忽视的点:dc.drawText()最后一个参数是对齐方式,它影响的不仅是水平对齐,在部分圆形码表上还涉及文本基线偏移。如果你的表盘用了圆形布局,建议用Graphics.TEXT_JUSTIFY_CENTER配合屏幕中心的x坐标,这样文本会自动居中,省去自己计算宽度。
4.6 在模拟器和真机上运行
在VS Code里,按F5可以打开模拟器。模拟器默认会用一个虚拟的方屏/圆屏设备启动。这里我要给一句忠告:模拟器上能正常显示汉字,不代表真机也能。原因很简单,模拟器用的是你PC上的字体渲染,你就算不加载任何自定义字体,drawText直接画中文也可能显示出来,因为操作系统帮你做了本地回退。真机没有这个能力,所以务必真机测。
真机调试需要先在Connect IQ开发者中心注册你的设备,然后在SDK Manager里安装对应的设备调试包。部署步骤是:手机安装Garmin Connect,手表开启开发者模式,通过USB或者Wi-Fi把编译好的PRG文件推送到手表。具体路径每个型号略有差异,但大方向一致。
5. 真机调试中常见的坑
5.1 编译报错:Could not resolve symbol
这种报错通常是因为代码里用了某个API,但你的SDK版本太低。比如BufferedBitmap.getDc()在Connect IQ 2.3.0之后才引入,如果你的工程target版本比较老,编译就会挂。解决办法是更新SDK版本,或者在manifest.xml里调高iq:minApiLevel。
但注意,minApiLevel调高意味着老设备不让装,你要确认自己的目标设备能接受。如果非要兼容老设备,那就老老实实退回逐像素绘制方案。
5.2 真机上汉字变成方块
这个问题有两层原因。如果你用的是默认字体,那是必然结果,因为系统字体没有汉字字形。如果你已经加载了自定义字体,但还是方块,那多半是字体文件没打进去。检查一下resources.xml里的filename路径是否正确、字体文件有没有放进工程目录。
我犯过的错误是:字体文件放在resources/根目录,结果Connect IQ构建时默认只扫描resources/drawables/、resources/fonts/等子目录,resources/根目录不是标准资源目录。把字体放到resources/fonts/下,问题就解决了。新版SDK在编译时不一定报错,它会静默忽略这个字体资源,导致真机只看到方块。
5.3 点阵字模显示错乱
错乱的症状一般是:汉字被拆成上下两半,或者位置偏了半个字。这十有八九是字模数据的排列顺序和你代码里的绘制逻辑不一致。比如你用PCtoLCD2002取模时选了“列行式”,但代码是按“行列式”解析,数据就全串位了。
解决办法:统一格式。我把标准约定写在这里,照做就能避免八成的错乱问题。
- 取模软件选择:横向取模、逐行式。
- 数据顺序:从左到右,从上到下。
- 每行字节数:16px宽度对应2字节,24px宽度对应3字节。
- 位序:高位在前,也就是说一个字节的bit7对应最左边一列。
5.4 应用启动慢或者闪退
如果采用字体子集化方案,文件超过200KB之后,在低端设备上启动就可能明显卡顿。解决办法是继续压缩子集,只保留出现的汉字。如果一个表盘需要显示几百个不重复的汉字,我建议重新评估产品设计,因为这在低端硬件上体验不会好。
点阵方案基本不会遇到这种问题,32字节一个字,就算放100个字也就3.2KB,怎么折腾都不会爆内存。
5.5 常见问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 汉字显示成方块 | 系统字体没有中文字形 | 使用自定义字体或点阵字模 |
| 字体资源没生效 | 字体文件放错目录 | 放到resources/fonts/下 |
| 字模显示上下分离 | 取模方式与解析逻辑不一致 | 统一为横向取模逐行式 |
| 应用启动卡顿 | 字体文件过大 | 子集化压缩或改点阵方案 |
| 编译找不到getDc | SDK版本过低 | 升级SDK,或者改用setPixel |
5.6 一个容易被忽略的小问题:设备圆屏裁切
佳明手表中像Fenix 6X、Instinct这样的设备屏幕形状不一样。同样的坐标,在方屏和圆屏上显示区域会差不少。点阵字模如果显示在屏幕边缘,可能会被圆角或表盘边框裁掉。建议把字模绘制区域控制在屏幕中心安全区,或者通过dc.getWidth()和dc.getHeight()动态计算起始坐标:
var width = dc.getWidth(); var height = dc.getHeight(); var startX = (width - 16 * glyphs.size()) / 2; var startY = (height - 16) / 2;这样在方屏、圆屏上都能保持居中,视觉上稳很多。
6. 文字资源和性能优化的最后一点建议
在实际做这个项目的过程中,我总结出几个纯经验性的结论,想单独拿出来多说几句。
第一,不要把字体子集化和点阵字模对立起来,两个方案可以混用。固定标签用点阵字模,动态组合的短句用子集字体,这样兼顾内存和灵活性。我最终的表盘就是这样做的:六个固定中文标签走点阵方案,一个需要拼接的“加油”提示语走子集字体,整个App体积控制在300KB以内。
第二,关于在模拟器里调试字模数据。模拟器和真机渲染英文数字几乎没差别,但中文字模因为全是自绘的像素点,模拟器上显示的就是真实效果,这个比字体资源方案还要靠谱。所以我建议在调字模阶段多用模拟器,在调字体加载阶段一定要真机。
第三,字模数据如果要在多个表盘项目里复用,建议单独建一个HanZiGlyphs.mc文件,把所有字模数据集中放在一起,不要散落在View代码里。后续维护的时候,加字只改这一个文件,其他代码一行都不用动。
最后再说一个小技巧:如果你要显示的是黑底白字,setPixel时用Graphics.COLOR_WHITE;如果是白底黑字,可以把字模数据里每一位都取反,或者绘制时判断bit为0就画黑点。我更喜欢后者,因为不动数据,只改一个判断条件,换肤就方便多了。
这套东西跑通之后,后续如果想做更复杂的多行中文布局,或者想支持24x24、32x32的大字,思路完全一样,只是数据量和坐标计算稍微调整。如果你也在做佳明表盘开发,希望这篇能帮你少走点弯路。