
写佳明手表表盘第一次在dc.drawText()里传入“你好世界”屏幕上一排整齐的方框——这个场景做过 Connect IQ 开发的中文开发者基本都经历过。问题不在代码而在 MonkeyC 内置字体压根不包含 CJK 字形。本文就围绕“佳明手表 MonkeyC 汉字显示 完整代码”这条主线把从零到能跑通的完整过程拆开讲清楚。我会先用最短篇幅解释为什么内置字体显示不了中文然后对比三条可行路线最终落到点阵字库方案上附带 HZK16 字模提取脚本和一份可编译运行的 MonkeyC 工程代码。1. 为什么佳明表盘上中文全是方框内置字体的字符边界1.1 系统字体只覆盖了“够用”的字符集Connect IQ 里的Graphics.drawText()依赖设备内置字体渲染文字而所有内置字体从设计之初就只覆盖拉丁字母、数字、常用标点和一些欧洲扩展字符。你可以把系统字体想象成一张很小的字符表里面没有为中文、日文、韩文这类 CJK 字形预留位置。所以在模拟器和真机上传入中文字符串时系统找不到对应字形只能回退成一个空心方框豆腐块或者干脆空白具体表现因设备和模拟器版本而异。1.2 模拟器与真机行为差异模拟器上跑上面代码大概率显示方框真机上很多时候也是方框个别机型甚至会由于字体回退逻辑不同而显示空白。这个差异很坑人因为你可能在模拟器上看到“还有救”的效果传到真机上反而更糟。遇到这种事不要怀疑代码直接确认当前使用的字体资源是否包含目标字符。1.3 确定问题边界既然内置字体没有中文字形那解决的思路就很清晰绕开系统字体渲染自己把汉字的像素画到屏幕上。这里的关键词是“自绘”。你不依赖drawText去找字形而是直接告诉屏幕“第几行第几列应该亮”。这个思路听起来原始但它绕开了整个字体回退机制只要你能拿到汉字的点阵数据就能稳定显示中文。2. 点阵字库选型三条技术路线里为什么只有它可行2.1 路线A自定义 FontResource——SDK 工具不支持 CJKConnect IQ SDK 确实允许引入自定义字体fonts资源里可以加载.font文件。但一个致命限制是SDK 的字体生成工具只接受 0x00 到 0xFF 范围内的字符也就是单字节字符集。中文是 GB2312 / GBK / Unicode 多字节字符直接超出工具能解析的范围。有人会想用 Unicode 范围较大的 TTF 字体文件塞进去我在项目里实际试过结果要么是编译器直接报“字符超出范围”要么是生成的字体文件里根本没有对应字形。原因在于 Connect IQ 的字体资源结构本身就不是为 CJK 设计的。所以这条路线从工具链层面就被堵死了不建议再往这个方向花时间。2.2 路线B整段文字渲染成 PNG——静态可以动态不行第二种常见做法把需要的汉字或整句中文用设计软件渲染成 PNG 图片放进drawables资源然后用dc.drawBitmap()画出来。代码极简dc.drawBitmap(x, y, Rez.Drawables.HeartRateLabel);这个方案非常适合固定文字比如表盘底部的“心率”、“步数”、“电量”这种万年不变的标签。缺点也非常明显一旦文字需要动态变化比如星期几、日期、天气你总不能把“星期一”到“星期日”全部做成 7 张图吧更别说拼接文字时还要人工对齐每个字的坐标。图片资源在大分辨率屏幕上还会吃内存一张 32x32 的中等尺寸 PNG 往往能被放大得模糊换成高清图又会带来包体积和内存压力。所以图片法适合“救急”不适合做通用中文渲染层。2.3 路线C点阵字库——有体积、有灵活性、可控点阵字库是嵌入式设备上显示中文的经典方案。每个汉字用一个固定尺寸的像素矩阵描述比如 16x16、24x24 或 32x32。1 表示亮0 表示灭。显示时把这些位逐像素画到屏幕上。优点很明显不依赖系统字体也不依赖 SDK 字体工具体积可控常用表盘 50 个汉字 16x16 点阵总共只要50 * 32 1600字节支持动态拼接任何文本只要字符在字库里都能实时渲染跨机型一致像素就是像素不存在字体回退问题。代价是需要自己处理“字库来源”和“位绘制”两部分逻辑。这正是本文要解决的核心问题。3. 准备开发环境Connect IQ SDK、MonkeyC 语法需要知道的最小集合3.1 安装 SDK Manager 与 IDE 插件开发 Connect IQ 应用前先去 Garmin 官网下载 Connect IQ SDK Manager这是管理 SDK 版本和模拟器的入口。安装完成后按喜好选择 Eclipse 插件或 Visual Studio Code 扩展。两边对 MonkeyC 语言的支持都一般没有太多智能提示但编译和调试功能是完整的。装好后打开 SDK Manager先安装一个稳定的 SDK 版本我用的 4.x 系列在目前主流手表上兼容性较好。然后启动模拟器选择一款手表型号建议选和你目标设备分辨率接近的型号方便后续适配。3.2 建立一个 Watch Face 工程在 IDE 里新建 Connect IQ Project模板选 Watch Face。工程里会自动生成manifest.xml、source/目录和resources/目录。App 入口类一般长这样using Toybox.Application as App; using Toybox.WatchUi as Ui; class HanziFaceApp extends App.AppBase { function initialize() { AppBase.initialize(); } function getInitialView() { return [ new HanziFaceView() ]; } }getInitialView()返回一个数组第一个元素是 View第二个是可选的 Delegate。表盘通常只需要 View。3.3 MonkeyC 必须明确的几个要点MonkeyC 长得像 Java但弱化了不少东西第一眼看到会有点不习惯。写本文代码前你需要对齐下面这几个基本点变量声明用var常量用const类型推断为主方法定义function 名字(参数) {}类继承用extends模块引入用using字符串用双引号String.toCharArray()可以得到 Char 数组每个 Char 是 16 位数值整型数值支持0x十六进制写法Graphics模块常简写为Gfx。不需要太追求语法美丽先跑通再优化。记住这三条就够了怎么画点、怎么画线、怎么在onUpdate里拿到设备上下文dc。4. 从HZK16提取字模Python脚本生成MonkeyC字库数组4.1 HZK16 的存储结构HZK16 是一种经典的 16x16 点阵汉字字库每个汉字占用固定 32 字节。16 行每行 16 个像素因为一个字节是 8 个像素所以一行正好需要 2 个字节。这 32 字节的内存布局有特定顺序前 16 字节存放每个像素行的左半边 8 像素后 16 字节存放每个像素行的右半边 8 像素。也就是说data[0] 第一行左半 data[1] 第二行左半 ... data[15] 第十六行左半 data[16] 第一行右半 data[17] 第二行右半 ... data[31] 第十六行右半这个顺序和某些字库把左右半行交叉存放的格式不同初次接触时很容易搞反。如果显示出来的汉字左右分裂、上下错位优先检查这里。4.2 区位码计算公式要定位某个汉字在 HZK16 里的位置就先要知道它在 GB2312 编码中的区位号。GB2312 里每个汉字由两个字节构成第一个字节是“区”第二个字节是“位”。汉字区的有效范围从0xA1到0xF7位从0xA1到0xFE。把两个字节分别减去0xA0就得到 1 到 94 的区号和位号。例如一个汉字的 GB2312 编码是0xD6D0那么区号 0xD6 - 0xA0 54 位号 0xD0 - 0xA0 48HZK16 的索引按“先区后位”展开每个区有 94 个字。所以文件偏移计算公式是offset ((区号 - 1) * 94 (位号 - 1)) * 32减 1 是因为文件的第一个汉字是区号 1、位号 1对应偏移 0。4.3 Python 提取脚本准备好 HZK16 字库文件后用下面这个 Python 脚本可以一次性生成 MonkeyC 能直接粘贴的字库数组。# generate_font.py # 用法: python3 generate_font.py 你好世界 # 输出: MonkeyC 的 const CHAR_SET 和 const FONT_DATA import sys HZK16_PATH HZK16 def char_to_quwei(char): # 将字符编码成 GB2312得到区号和位号1-based gb char.encode(gb2312) qu gb[0] - 0xA0 wei gb[1] - 0xA0 return qu, wei def read_font_bytes(char): qu, wei char_to_quwei(char) offset ((qu - 1) * 94 (wei - 1)) * 32 with open(HZK16_PATH, rb) as f: f.seek(offset) return f.read(32) def main(text): chars list(text) print(const CHAR_SET %s; % text) print(const FONT_DATA [) for c in chars: data read_font_bytes(c) hex_str , .join(0x%02x % b for b in data) print( %s, // %s % (hex_str, c)) print(];) if __name__ __main__: if len(sys.argv) 2: print(请传入要生成字库的字符串例如: python3 generate_font.py \你好世界\) sys.exit(1) main(sys.argv[1])运行python3 generate_font.py 你好世界一二三四五六日脚本会输出类似这样的代码块const CHAR_SET 你好世界一二三四五六日; const FONT_DATA [ 0x00, 0x00, ... // 你 0x00, 0x00, ... // 好 ];把这部分输出复制到 MonkeyC 源文件顶部作为全局常量。注意源文件保存时编码一定要用 UTF-8否则 Connect IQ 编译器对中文字符串的处理可能变成乱码。这也是新手很常见的坑编码错了后面找问题会找一晚上。5. 完整可运行的MonkeyC代码表盘集成与绘制引擎拆解5.1 字库数据结构的组织方式在 MonkeyC 工程里我建议把字库独立放在源文件顶部不塞进类里方便后续通过脚本整体替换。using Toybox.Graphics as Gfx; using Toybox.Lang; using Toybox.System as Sys; using Toybox.Time; using Toybox.Time.Gregorian; using Toybox.WatchUi as Ui; // 这里粘贴 Python 脚本输出的字库定义 const CHAR_SET 你好世界一二三四五六日; const BYTES_PER_CHAR 32; // 用脚本生成后替换为真实数据 // const FONT_DATA [ // 0x00, 0x00, ... // ];CHAR_SET与FONT_DATA的索引必须一一对应。也就是说CHAR_SET的第 0 个字符“你”它的字模就存放在FONT_DATA[0*32]到FONT_DATA[0*3231]。5.2 绘制引擎从字节到像素有了字库数据后核心工作就是把一个字节里的 8 个 bit 变成屏幕上的 8 个像素。这里用dc.setPixel(x, y, color)实现最直观也最容易理解。下面的绘制函数按 16x16 点阵逐行扫描先把左半字节的每一位读出来再把右半字节的每一位读出来function drawChineseChar(dc, index, x, y, color) { var base index * BYTES_PER_CHAR; for (var row 0; row 16; row) { var left FONT_DATA[base row]; var right FONT_DATA[base 16 row]; for (var bit 0; bit 8; bit) { var mask 0x80 bit; if ((left mask) ! 0) { dc.setPixel(x bit, y row, color); } if ((right mask) ! 0) { dc.setPixel(x 8 bit, y row, color); } } } }这段代码读起来长但逻辑很简单0x80对应二进制10000000向右移位后依次检查每个 bit 是否为 1。左半字节画在(x, yrow)到(x7, yrow)之间右半字节画在(x8, yrow)到(x15, yrow)之间。然后是文本渲染入口它负责把字符串拆成单个字符逐个查找索引并绘制function findCharIndex(c) { var chars CHAR_SET.toCharArray(); var target c.toCharArray(); for (var i 0; i chars.size(); i) { if (chars[i] target[0]) { return i; } } return -1; } function drawChineseText(dc, text, x, y, color) { var len text.length(); for (var i 0; i len; i) { var c text.substring(i, i 1); var index findCharIndex(c); if (index 0) { drawChineseChar(dc, index, x i * 16, y, color); } } }findCharIndex每次都会把CHAR_SET转成数组做线性查找。表盘上文本量不大十几个字符内完全够用。如果以后要渲染大量动态文本可以改成构建一张哈希表但那是后话。5.3 在 Watch Face 中集成把上面的函数放进HanziFaceView然后在onUpdate里调用即可class HanziFaceView extends Ui.WatchFace { function initialize() { Ui.WatchFace.initialize(); } function onLayout(dc) { } function onShow() { } function onHide() { } function onUpdate(dc) { dc.setColor(Gfx.COLOR_BLACK, Gfx.COLOR_BLACK); dc.clear(); var width dc.getWidth(); var height dc.getHeight(); // 系统字体绘制数字时间 var clockTime Sys.getClockTime(); var hourStr clockTime.hour 10 ? 0 clockTime.hour : clockTime.hour.toString(); var minStr clockTime.min 10 ? 0 clockTime.min : clockTime.min.toString(); var timeStr hourStr : minStr; dc.setColor(Gfx.COLOR_WHITE, Gfx.COLOR_BLACK); dc.drawText(width / 2, height / 2 - 50, Gfx.FONT_NUMBER_MEDIUM, timeStr, Gfx.TEXT_JUSTIFY_CENTER); // 中文示例文本 drawChineseText(dc, 你好世界, width / 2 - 32, height / 2, Gfx.COLOR_WHITE); // 中文星期 var info Gregorian.info(Time.now(), Time.FORMAT_MEDIUM); var weekdays 一二三四五六日; var weekdayStr weekdays.substring(info.day_of_week - 1, info.day_of_week); drawChineseText(dc, weekdayStr, width / 2 - 8, height / 2 22, Gfx.COLOR_WHITE); } }这段代码覆盖了表盘最基础的两个中文应用场景固定中文标语“你好世界”以及动态变化的中文星期。Gregorian.info(Time.now(), Time.FORMAT_MEDIUM)返回的day_of_week取值 1 到 7其中 1 表示周日2 表示周一依次类推。weekdays字符串中第 0 个字符是“日”正好和day_of_week 1对应。5.4 在模拟器上第一次看到中文代码写完后连上模拟器点击 Run。如果字库数据正确你会在表盘中央看到“你好世界”这 4 个中文字。因为是逐像素画出来的所以它不受设备字体影响模拟器上是什么样子真机上基本就是什么样子。到这里佳明表盘显示中文的核心目标已经达成。剩下的问题从“能不能显示”变成了“显示得够不够快、够不够清晰、够不够稳定”。6. 性能优化、真机适配与最容易踩的扩展坑6.1 setPixel 的性能瓶颈与缓存思路每个 16x16 汉字需要调用 256 次setPixel。如果表盘上显示 10 个汉字就是 2560 次单点绘制。在模拟器上毫无压力但在部分低端型号真机上如果每次onUpdate全屏重绘可能会有可感知的延迟。一个简单有效的优化把不常变动的文本预先绘制到位图缓存里。MonkeyC 的Graphics.Bitmap.createFromData()可以从原始数据创建一个位图对象然后用dc.drawBitmap()一次性绘制。HZK16 的数据顺序需要重排成“每行连续 2 字节”的位图格式转换逻辑如下function buildBitmapData(index) { var base index * BYTES_PER_CHAR; var bmpData new[32]; for (var row 0; row 16; row) { bmpData[row * 2] FONT_DATA[base row]; bmpData[row * 2 1] FONT_DATA[base 16 row]; } return bmpData; }然后可以创建Gfx.Bitmap.createFromData(bmpData, 16, 16, Gfx.BitmapFormat.MONOCHROME)并保存。后续重绘时直接dc.drawBitmap(x, y, bitmap)。需要注意不同 SDK 版本对BitmapFormat的支持有细微差别建议自己在模拟器上跑一个最小用例验证颜色方向。6.2 加入中文日期与动态文本时的设计显示日期在表盘上是最常见的需求。年月日全用中文时画面占用宽度会比较大。以 16x16 点阵为例一个汉字占 16 像素宽10 个字符就是 160 像素。小型圆形手表上这个宽度很容易超过表盘半径。这时候要改变设计思路年份和数字用系统阿拉伯数字中文只负责单位和星期。比如“2025年01月05日 周日”可以只把“年月日”三个字和“周日”两个字用点阵字库画数字继续用系统字体。这样既保证中文存在感又控制了横向宽度。再提醒一下表盘的onPartialUpdate机制适合秒级刷新。它默认只在部分区域更新系统会在收到onPartialUpdate触发时提供较小分辨率的dc。在这个回调里不要做整屏setPixel扫字避免性能问题。把秒显做成独立区域用系统数字字体中文文本只放在onUpdate的静态区域这是最稳妥的表盘组合。6.3 16x16 不够大怎么办16x16 点阵在圆形屏上确实偏小文字密集时辨识度一般。想上 24x24 甚至 32x32 也完全可以但要注意三点字模文件变了比如 24x24 点阵通常是单色 BDF 或开源点阵字库格式每行 24 个像素需要 3 个字节一个汉字占24 * 3 72字节绘制循环里的“左半/右半”逻辑要改成“每行 N 字节”的通用读取逻辑像素点数量增加性能压力更大尽量配合BufferedBitmap或位图缓存。实际项目中我建议先做 16x16 版本验证功能确认 UI 布局后再考虑放大。不要在第一步就卡在“字要大”上。6.4 编码、内存、真机调试的几个顽固坑源文件编码是第一个坑。CHAR_SET里的中文字符串必须以 UTF-8 编码保存。如果 IDE 默认编码是本地系统编码会出现编译通过但运行时乱码的情况。第二个坑是字库覆盖范围。GB2312 总共只有 6000 多个常用汉字HZK16 也只能覆盖这些。生僻字、繁体字或某些地名用字会直接提示编码失败。处理办法是表盘显示的中文计划好在开发时确定下来不要接受任意用户输入。第三个坑是真机调试。模拟器上字体渲染和像素绘制都正常但真机的内存管理更严格。大数组尽量声明为全局常量不要在onUpdate里反复创建对象。FONT_DATA这种静态字库放全局是安全的但不建议在绘制方法里每次新建CharArray重复分配会触发垃圾回收加剧功耗。最后一个坑是圆形屏边缘。佳明有不少圆形表盘屏幕四角会被裁掉。布局时不要把中文文字顶到边缘留出至少 20 像素的内边距比较安全。在我实际开发表盘的过程中最深的体会是不要把“中文显示”当成一个字体问题而要当成一个渲染问题。系统字体走不通就自绘像素自绘像素的核心又在于数据结构是否清晰。字库数据用脚本生成、绘制引擎保持简单、布局上给中文留出足够横向宽度这样一整套方案下来不仅表盘能用后续要做天气中文预报、通知提醒中文摘要也都能复用同一套点阵渲染层。最后再分享一个实用小习惯字库脚本和工程代码放在同一个仓库里以后想往表盘里加汉字改一行python3 generate_font.py的参数再粘贴替换即可不需要手动整理任何字模数据。