跳到正文
Carol's Blog
返回

Garmin表盘开发入门:Monkey C与Connect IQ完全指南

最近想给Garmin手表装一个好看的表盘,在GitHub上发现了Segment34这个开源项目——采用复古的34段数码管显示风格,集成了心率、天气、月相等多种数据,视觉上非常独特。

既然是开源项目,正好可以借着阅读源码的机会,系统梳理一下Garmin表盘开发的完整流程。

先看一下成品效果。后面提到的布局、字体、天气图标和后台数据刷新,都会落实到这个表盘界面上。

Segment34 表盘在 Garmin 手表上的实际效果

Connect IQ平台概述

Connect IQ是Garmin的第三方应用生态,开发者可以为Garmin手表创建:

表盘开发是最常见的场景,因为它直接面向用户,每次抬手都能看到。

开发环境搭建

安装Connect IQ SDK

从Garmin开发者网站下载SDK:

https://developer.garmin.com/connect-iq/sdk/

macOS默认安装路径:

~/Library/Application Support/Garmin/ConnectIQ/

VS Code扩展

推荐使用VS Code开发,安装 Garmin Connect IQ 扩展,提供:

开发者密钥

发布应用需要签名密钥:

openssl genrsa -out developer_key.pem 4096
openssl pkcs8 -topk8 -inform PEM -outform DER \
    -in developer_key.pem \
    -out developer_key.der -nocrypt

Monkey C语言

Monkey C是Garmin专门为Connect IQ设计的编程语言,语法类似Java和Dart,但针对嵌入式设备做了优化。

由于astro markdown组件没有对monkey-c进行语法高亮的优化,所以下面关于monkey-c的代码都以typescript语法高亮(写法挺像的)。

基本语法

using Toybox.Application;
using Toybox.WatchUi;
using Toybox.System;

class MyWatchFace extends WatchUi.WatchFace {
    
    // 构造函数
    function initialize() {
        WatchFace.initialize();
    }
    
    // 布局加载
    function onLayout(dc as Dc) as Void {
        setLayout(Rez.Layouts.WatchFace(dc));
    }
    
    // 每帧更新
    function onUpdate(dc as Dc) as Void {
        var clockTime = System.getClockTime();
        var label = View.findDrawableById("TimeLabel") as Text;
        label.setText(clockTime.hour.format("%02d"));
        View.onUpdate(dc);
    }
}

核心模块

Monkey C通过Toybox命名空间组织API:

模块功能
Toybox.Application应用生命周期、存储
Toybox.WatchUi表盘UI框架
Toybox.System系统信息、时钟
Toybox.Graphics图形绘制
Toybox.Time时间处理
Toybox.Lang基础类型
Toybox.Communications网络请求
Toybox.Background后台服务
Toybox.SensorHistory传感器历史数据
Toybox.Weather天气数据

类型系统

Monkey C是强类型语言,支持以下类型:

// 基本类型
var number = 42;           // Number
var text = "Hello";        // String
var flag = true;           // Boolean
var pi = 3.14;             // Float/Double

// 集合类型
var arr = [1, 2, 3];       // Array
var dict = {               // Dictionary
    "key" => "value",
    "count" => 10
};

// 类型声明
function add(a as Number, b as Number) as Number {
    return a + b;
}

项目结构

一个标准的Connect IQ表盘项目结构如下:

MyWatchFace/
├── manifest.xml           # 应用清单
├── monkey.jungle          # 构建配置
├── source/
│   ├── MyWatchFaceApp.mc  # 应用入口
│   └── MyWatchFaceView.mc # 表盘视图
├── resources/
│   ├── layouts/
│   │   └── layout.xml     # UI布局
│   ├── fonts/
│   │   ├── fonts.xml      # 字体配置
│   │   └── *.fnt          # 字体文件
│   ├── drawables/
│   │   ├── drawables.xml  # 图标配置
│   │   └── *.png          # 图标资源
│   └── strings/
│       └── strings.xml    # 多语言字符串
├── resources-round-240x240/  # 多尺寸适配
└── build/                 # 编译输出

manifest.xml

应用清单声明了设备支持、权限等信息:

<?xml version="1.0"?>
<iq:manifest version="3" xmlns:iq="http://www.garmin.com/xml/connectiq">
    <iq:application 
        id="your-app-uuid" 
        type="watchface" 
        name="@Strings.AppName" 
        entry="MyWatchFaceApp"
        minApiLevel="3.2.0">
        
        <iq:products>
            <iq:product id="fenix7"/>
            <iq:product id="fr255"/>
        </iq:products>
        
        <iq:permissions>
            <iq:uses-permission id="Positioning"/>
            <iq:uses-permission id="SensorHistory"/>
        </iq:permissions>
    </iq:application>
</iq:manifest>

layout.xml

使用XML声明式布局:

<layout id="WatchFace">
    <label id="TimeLabel" 
           x="center" y="90" 
           font="@Fonts.id_segments80" 
           justification="Graphics.TEXT_JUSTIFY_CENTER" 
           color="0xFFFF00"/>
    
    <bitmap id="WeatherIcon" 
            x="120" y="50" 
            filename="../drawables/weather.png"/>
</layout>

在代码中引用:

function onUpdate(dc as Dc) as Void {
    var timeLabel = View.findDrawableById("TimeLabel") as Text;
    timeLabel.setText("12:34");
    View.onUpdate(dc);
}

以Segment34为例学习表盘开发

Segment34是GitHub上的一个开源Garmin表盘项目,作者hurricane312采用复古的34段数码管显示风格,视觉效果非常出色。这篇文章以它作为学习案例,分析一个成熟表盘项目是如何组织的。

功能特性

Segment34实现了以下功能:

数据源

看看这个项目是如何整合传感器数据的:

// 心率
var activityInfo = Activity.getActivityInfo();
var hr = activityInfo.currentHeartRate;

// 或从历史数据获取
var sample = ActivityMonitor.getHeartRateHistory(1, true).next();

// 步数
var steps = ActivityMonitor.getInfo().steps;

// 天气
var weather = Weather.getCurrentConditions();
var temp = weather.temperature;

// 电池
var battery = System.getSystemStats().battery;

自定义字体

Segment34使用自定义位图字体实现数码管效果:

<fonts>
    <font id="id_segments80" filename="segments80.fnt" 
          antialias="true" filter="#1234567890:"/>
    <font id="id_led" filename="led.fnt" 
          antialias="false" filter="0123456789-"/>
    <font id="id_moon" filename="moon.fnt" antialias="true"/>
</fonts>

filter属性限制了可显示的字符集,减小字体文件体积。

天气图标

项目内置了多种天气状态图标:

图标天气
多云天气图标多云
下雨天气图标下雨
下雪天气图标下雪

月相显示

使用自定义字体实现月相图形化:

月相字体对应的 8 种月相字形

月相计算算法:

hidden function moon_phase(time) {
    var jd = julian_day(time.year, time.month, time.day);
    var days_since_new_moon = jd - 2459966;
    var lunar_cycle = 29.53;
    var phase = ((days_since_new_moon / lunar_cycle) * 100).toNumber() % 100;
    var into_cycle = (phase / 100.0) * lunar_cycle;

    if (into_cycle < 3) return "0";      // 新月
    else if (into_cycle < 6) return "1"; // 蛾眉月
    else if (into_cycle < 10) return "2";
    else if (into_cycle < 14) return "3"; // 上弦月
    else if (into_cycle < 18) return "4"; // 盈凸月
    else if (into_cycle < 22) return "5"; // 满月
    else if (into_cycle < 26) return "6"; // 亏凸月
    else if (into_cycle < 29) return "7"; // 残月
    else return "0";
}

屏幕适配

支持多种屏幕尺寸:

function setStressAndBodyBattery(dc) as Void {
    var barTop = 91;
    var fromEdge = 10;
    
    if (dc.getHeight() == 240) {
        barTop = 81;
        fromEdge = 6;
    }
    if (dc.getHeight() == 280) {
        fromEdge = 14;
    }
    
    dc.fillRectangle(fromEdge, barTop, 3, 80);
}

对应的资源目录:

resources/                    # 默认 (260x260)
resources-round-240x240/      # 240x240 屏幕
resources-round-280x280/      # 280x280 屏幕

这种多资源目录的设计值得借鉴——新建设备支持时只需复制布局文件并调整坐标,无需在代码中硬编码太多分支。

编译与部署

命令行构建

monkeyc -o MyWatchFace.prg \
        -f monkey.jungle \
        -y developer_key.der \
        -w

VS Code一键构建

Cmd+Shift+P → "Monkey C: Build for Device"

安装到手表

方法一:USB传输

cp build/fenix7/MyWatchFace.prg /Volumes/GARMIN/Apps/

方法二:模拟器调试

VS Code中选择 “Run in Simulator”,在模拟器中预览效果。

发布到Connect IQ Store

打包发布版本:

monkeyc -o MyWatchFace.iq \
        -f monkey.jungle \
        -y developer_key.der \
        -r -e 2

然后上传到 Garmin Developer Portal 提交审核。

网络请求与后台服务

表盘需要从网络获取自定义数据时,不能直接在onUpdate()中请求,因为每次亮屏都会调用,会导致卡顿和耗电。

架构设计

正确做法是使用后台服务定时请求,然后缓存到本地:

┌─────────────────────────────────────────────────────────────────┐
│                    网络请求架构                                  │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  ┌─────────────────┐     每5-30分钟      ┌─────────────────┐    │
│  │  后台服务        │ ─────────────────→ │  API 服务器      │    │
│  │  ServiceDelegate│                    │                 │    │
│  └────────┬────────┘                    └─────────────────┘    │
│           │                                                     │
│           │ Background.exit(data)                               │
│           ↓                                                     │
│  ┌─────────────────┐                                            │
│  │   本地存储缓存   │                                            │
│  │  Storage.setValue│                                           │
│  └────────┬────────┘                                            │
│           │                                                     │
│           │ 亮屏时读取                                           │
│           ↓                                                     │
│  ┌─────────────────┐                                            │
│  │  WatchFace View  │                                            │
│  │  onUpdate()      │                                            │
│  └─────────────────┘                                            │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

后台服务实现

(:background)
class CustomDataService extends System.ServiceDelegate {
    
    function onTemporalEvent() {
        Communications.makeWebRequest(
            "https://api.example.com/data",
            {},
            {
                :method => Communications.HTTP_REQUEST_METHOD_GET,
                :responseType => Communications.HTTP_RESPONSE_CONTENT_TYPE_TEXT_ENUM
            },
            method(:onResponse)
        );
    }
    
    function onResponse(responseCode, data) {
        if (responseCode == 200 && data != null) {
            Application.Storage.setValue("customData", data["fields"]);
            Application.Storage.setValue("lastUpdate", Time.now().value());
        }
        Background.exit(null);
    }
}

在应用入口注册:

(:background)
class MyApp extends Application.AppBase {
    
    function initialize() {
        AppBase.initialize();
        
        // 注册后台服务,最小间隔5分钟
        if (Background.getTemporalEventRegisteredTime() == null) {
            Background.registerForTemporalEvent(new Time.Duration(15 * 60));
        }
    }
    
    public function getServiceDelegate() as [System.ServiceDelegate] {
        return [new CustomDataService()];
    }
}

表盘读取缓存

class MyView extends WatchUi.WatchFace {
    
    var cachedData = null;
    
    function onShow() {
        cachedData = Application.Storage.getValue("customData");
    }
    
    function onUpdate(dc as Dc) as Void {
        if (cachedData != null) {
            var label = View.findDrawableById("CustomLabel") as Text;
            label.setText(cachedData["value"].toString());
        }
        View.onUpdate(dc);
    }
}

限制与注意事项

限制说明
最小请求间隔5分钟
数据存储大小单项最大32KB
后台服务内存有限制,避免大量处理
manifest权限需声明Communications权限

支持的设备

Segment34支持广泛的Garmin设备:

系列设备型号
Fenix 6fenix6, fenix6pro, fenix6s, fenix6xpro
Fenix 7fenix7, fenix7pro, fenix7s, fenix7x
Fenix 8fenix8solar 47mm/51mm
Forerunnerfr245, fr255, fr745, fr945, fr955
Enduroenduro, enduro3
Descentdescentmk2, descentmk2s
MARQ全系列
Vivoactivevivoactive4

添加新设备支持只需在manifest.xml中添加product id,或通过VS Code命令Monkey C: Edit Products操作。

调试技巧

日志输出

function onUpdate(dc as Dc) as Void {
    System.println("Debug: onUpdate called");
    System.println("Width: " + dc.getWidth());
}

在模拟器控制台查看日志输出。

常见错误码

错误码含义
-200请求被取消
-201无网络连接
-202连接超时
-203数据解析失败
-403权限问题(Fenix 6常见)

小结

通过分析Segment34这个开源项目,可以学到Garmin表盘开发的几个关键点:

如果想基于Segment34二次开发,可以直接fork项目修改。如果从零开始,建议先用VS Code的Connect IQ扩展创建模板项目,再逐步添加功能。

Segment34项目地址:github.com/hurricane312/Segment34

参考资料


分享这篇文章:
通过邮件分享这篇文章

分享到微信

微信对普通网页没有开放通用直连分享协议。更稳妥的方式是复制链接、扫码打开,或在支持的设备上调用系统分享。

下一篇
MacOS中manim安装问题