跑步前、通勤路上、开会间隙,想快速看一眼自选股,但又不想拿起手机。
手机上的股票软件当然功能强大:K 线、盘口、新闻、研报、公告,一应俱全。但正因为它太完整,很多时候反而不适合“只看一眼”。
手表上的股票应用,不应该是手机 App 的缩小版。
它更像一个极简仪表盘,只回答几个问题:
我关心的股票现在多少钱?涨了还是跌了?涨跌幅是多少?数据是什么时候刷新的?
最近我基于 Garmin Connect IQ 和 Monkey C,在 Fenix 7 上做了一个 A 股行情应用。它可以在手表上显示自选股行情,支持刷新、翻页、股票管理和本地保存。
这篇文章就记录一下完整开发过程,以及我在手表应用设计上的一些思考。
当前应用运行在 Garmin Fenix 7 模拟器中,主界面大致如下:

界面结构非常简单:
顶部:当前时间中间:股票代码、当前价、涨跌额、涨跌幅底部:页码 + 最后刷新时间
这不是一个完整的交易终端,而是一个腕上行情观察器。
它的目标是:
抬腕一秒钟,知道自选股有没有明显变化。
翻看股票代码列表,可以添加目前不限量的股票,可以上下选择,选择要更改的股票代码和添加的股票:
下面这段可以直接放进公众号里,作为“按键使用说明”部分。
这个股票应用主要围绕 Garmin Fenix 7 的实体按键设计,不依赖触屏也可以完成大部分操作。
在主行情页面,可以看到当前页的股票行情,包括股票代码、价格、涨跌额和涨跌幅。

主行情页按键:
START / ENTER 进入股票管理页面UP 上一页股票DOWN 下一页股票MENU 打开功能菜单触摸屏点击 手动刷新行情
如果自选股数量比较多,应用会分页显示。当前页码会显示在底部,例如:
P1/3 08:08:31含义是:
当前第 1 页总共 3 页最后刷新时间 08:08:31
进入股票管理页面后,可以查看当前保存的股票列表,也可以新增、编辑或删除股票。

股票管理页按键:
UP 光标上移DOWN 光标下移START 短按 编辑当前股票,或进入新增股票START 长按 删除当前股票MENU 删除当前股票BACK 返回主行情页
列表最后一项是:
+ Add Stock选中它后按 START,就可以新增一只股票。
股票编辑页面用于输入或修改股票代码。

编辑时,股票代码被拆成两部分:
市场前缀:sh / sz六位股票代码:000001
例如:
sz000001sh600000
股票编辑页按键:
UP 当前位 +1,或切换 sh/szDOWN 当前位 -1,或切换 sh/szSTART 移动到下一位;最后一位后保存长按 UP 回到上一位MENU 回到上一位BACK 取消编辑并返回
编辑顺序是:
市场前缀 -> 第 1 位数字 -> 第 2 位数字 -> 第 3 位数字 -> 第 4 位数字 -> 第 5 位数字 -> 第 6 位数字 -> 保存举例来说,如果要输入:
sh600000大致流程是:
先把市场切换为 sh然后依次调整 6 位数字为 600000最后按 START 保存
功能菜单可以通过主行情页的 MENU 键打开。
菜单项说明:
Refresh 手动刷新行情Next Page 下一页股票Prev Page 上一页股票Manage Stocks 进入股票管理页面Reset Stocks 恢复默认股票列表About 输出应用说明信息
这个应用的按键逻辑整体可以概括成一句话:
主界面看行情,START 管股票,UP/DOWN 翻页或移动,BACK 返回。下面我没来介绍一下整个工程的设计流程和细节。
这个工程是一个标准的 Garmin Connect IQ 应用,主要代码在 source 目录:
source/ Stock_ApiApp.mc 应用入口 Stock_ApiView.mc 主行情页面 Stock_ApiDelegate.mc 主页面按键处理 Stock_ApiMenuDelegate.mc 菜单处理 Stock_ApiStockListView.mc 股票管理列表 Stock_ApiSymbolEditView.mc 股票代码编辑页面
资源文件在 resources 目录:
resources/ menus/menu.xml settings/settings.xml settings/properties.xml strings/strings.xml
Garmin 应用的基本模式可以概括为:
App 负责启动应用View 负责显示界面Delegate 负责处理按键、触摸、菜单Resources 负责字符串、菜单和配置
入口代码如下:
class Stock_ApiApp extends Application.AppBase {function initialize() {AppBase.initialize();}// Garmin 系统启动应用时,会调用这个方法获取初始页面。function getInitialView() as [Views] or [Views, InputDelegates] {var view = new Stock_ApiView();// 返回主页面,以及这个页面对应的输入代理。return [ view, new Stock_ApiDelegate(view) ];}}
这段代码很短,但它决定了应用启动后的第一屏:
Stock_ApiView + Stock_ApiDelegate也就是主行情页面和它的按键处理逻辑。
核心逻辑集中在 Stock_ApiView.mc。
它负责几件事:
读取股票列表请求行情数据解析接口返回保存本地设置绘制行情界面处理翻页刷新
下面是整个软件的编译环境,是用VS Code + Monkey C + Garmin SDK进行搭建的:

初始化时,应用会先加载股票列表:
function initialize() {View.initialize();_symbols = new Lang.Array<Lang.String>[0];_pageIndex = 0;// 启动时读取股票列表。loadSettings();_quotes = new Lang.Array<Lang.Dictionary>[0];_message = "Tap refresh";_lastRefresh = "--:--:--";_timer = null;_loading = false;}
页面显示时,自动刷新并启动定时器:
function onShow() as Void {loadSettings();// 页面出现时立即拉取一次行情。refresh();// 后续每 60 秒自动刷新。startTimer();}
定时器逻辑也很直接:
function startTimer() as Void {stopTimer();_timer = new Timer.Timer();// 每 60000 毫秒,也就是 60 秒,调用 refresh()。_timer.start(method(:refresh), 60000, true);}
这就是主页面的数据生命周期:
页面出现 -> 读取股票列表 -> 请求行情 -> 解析结果 -> 重绘界面 -> 定时刷新用户习惯输入的是:
sz000001sh600000sz300750
但东方财富接口需要的是另一种格式:
0.0000011.6000000.300750
其中:
0 代表深圳1 代表上海
所以项目里需要一层转换:
function symbolToSecid(symbolasString) as String {// 先统一格式:去空格、转小写。var s = normalizeUserSymbol(symbol);var code = "";// 上海股票:sh600000 -> 1.600000if (s.length() == 8 && s.find("sh") == 0) {var shCodeValue = s.substring(2, s.length());if (shCodeValue != null) {code = shCodeValue as String;if (isSixDigits(code)) {return "1." + code;}}return "";}// 深圳股票:sz000001 -> 0.000001if (s.length() == 8 && s.find("sz") == 0) {var szCodeValue = s.substring(2, s.length());if (szCodeValue != null) {code = szCodeValue as String;if (isSixDigits(code)) {return "0." + code;}}return "";}return "";}
这段代码看起来普通,但它体现了一个很重要的设计原则:
用户输入要符合直觉,接口格式由程序负责转换。
不要让用户记住 0.000001 这种接口编码。用户只需要知道 sz 和 sh。
这个项目使用东方财富接口进行行情测试:
var _apiBaseUrl as String = "https://push2.eastmoney.com/api/qt/ulist.np/get";
请求时拼装参数:
var params = {"fltt" => "2","invt" => "2",// f12 股票代码// f13 市场// f14 股票名称// f2 当前价// f3 涨跌幅// f4 涨跌额"fields" => "f12,f13,f14,f2,f3,f4",// 多个股票用逗号拼接,例如:// 0.000001,1.600000,0.300750"secids" => secids};
然后通过 Garmin 的 Communications.makeWebRequest() 发起网络请求:
Communications.makeWebRequest(_apiBaseUrl,params,options,// 网络请求是异步的。// 数据返回后,系统会调用 onQuoteResponse()。method(:onQuoteResponse));
这里需要注意:Garmin 的网络请求是异步的。
也就是说,程序不会停下来等待接口返回。请求发出后,应用继续运行。等数据回来,系统再调用回调函数:
function onQuoteResponse(responseCode as Number, data) as Void {_loading = false;if (responseCode != 200) {_message = "HTTP " + responseCode;WatchUi.requestUpdate();return;}try {// 把接口返回解析成页面可直接使用的数据。_quotes = parseQuotes(data);if (_quotes.size() == 0) {_message = "No quote data";} else {_message = "";}_lastRefresh = currentTimeText(true);} catch (ex) {_message = "Parse failed";System.println(ex.getErrorMessage());}// 通知 Garmin 系统重新绘制页面。WatchUi.requestUpdate();}
其中最关键的一句是:
WatchUi.requestUpdate();它的意思是:
数据已经变化,请重新调用
onUpdate()绘制界面。
东方财富返回的数据主要在:
data.diff每一项里有多个字段。项目里只取最关键的几个:
function parseQuotes(data) as Array<Dictionary> {var result = new Lang.Array<Lang.Dictionary>[0];if (!(data instanceof Dictionary)) {return result;}var root = data as Dictionary;if (!root.hasKey("data")) {return result;}var body = root["data"] as Dictionary;if (!body.hasKey("diff")) {return result;}var diff = body["diff"] as Array<Dictionary>;for (var i = 0; i < diff.size(); i++) {var item = diff[i];// f12 是股票代码,f13 是市场编号。var symbol = normalizeSymbol(textValue(item, "f12"),textValue(item, "f13"));result.add({"symbol" => symbol,"name" => displayCode(symbol),"price" => trimNumber(textValue(item, "f2")),"change" => trimSignedNumber(signedText(item, "f4")),"percent" => trimSignedNumber(signedText(item, "f3"))} as Dictionary);}return result;}
解析后的数据结构更适合界面绘制:
{symbol: "sz000001"name: "000001"price: "11.32"change: "+0.19"percent: "+1.71"}
接口字段可能很复杂,但界面真正需要的只有几个。
这也是小屏应用开发中的常见思路:
数据源可以复杂,但显示模型必须简单。
在调试过程中,我遇到一个很有意思的问题。
原本我希望一页显示 5 只股票,于是设置:
var _pageSize as Number = 5;但实际运行时,有时页面只显示 4 个。
原因并不是绘图少画了一行,而是接口可能没有返回其中某个股票的数据。如果只请求 5 个,返回可能只有 4 条。
所以最终采用了这个策略:
// 每页请求 6 个,因为行情接口偶尔可能漏掉其中一个。// 但 Fenix 7 屏幕仍然只显示 5 行。var _pageSize as Number = 6;var _displayPageSize as Number = 5;
请求时按照 _pageSize 取股票:
function buildSecids() as String {var result = "";// 每页向后推进 6 个股票。var start = _pageIndex * _pageSize;var end = start + _pageSize;if (end > _symbols.size()) {end = _symbols.size();}for (var i = start; i < end; i++) {var secid = symbolToSecid(_symbols[i]);if (secid != "") {if (result != "") {result += ",";}result += secid;}}return result;}
绘制时只显示 _displayPageSize 行:
var count = _quotes.size();// 即使接口返回了 6 条,屏幕最多只画 5 条。if (count > _displayPageSize) {count = _displayPageSize;}for (var i = 0; i < count; i++) {drawQuote(dc,_quotes[i],leftX,top + i * rowHeight,rightX - leftX,rowHeight);}
这个设计很像工程里的“缓冲区”:
数据层多拿一点显示层保持克制
如果强行显示 6 行,Fenix 7 的圆屏会变得非常拥挤。所以更合理的方式是:多请求一个备用,但视觉上仍然只保留 5 行。
Garmin 的界面可以使用 XML 布局,但这个项目的主页面选择直接在 onUpdate(dc) 里绘制。
原因很简单:Fenix 7 是圆形屏幕。
圆屏的可用空间并不是一个完整矩形。靠近边缘的位置会被表圈和圆形裁切影响。
主绘制逻辑如下:
function onUpdate(dc as Dc) as Void {var w = dc.getWidth();var h = dc.getHeight();// 清空背景。dc.setColor(Graphics.COLOR_BLACK, Graphics.COLOR_BLACK);dc.clear();// 顶部显示当前时间。dc.setColor(Graphics.COLOR_WHITE, Graphics.COLOR_TRANSPARENT);dc.drawText(w / 2,6,Graphics.FONT_SMALL,currentTimeText(false),Graphics.TEXT_JUSTIFY_CENTER);var footerY = h - 28;if (_quotes == null || _quotes.size() == 0) {dc.setColor(Graphics.COLOR_LT_GRAY, Graphics.COLOR_TRANSPARENT);dc.drawText(w / 2,h / 2 - 10,Graphics.FONT_SMALL,_message,Graphics.TEXT_JUSTIFY_CENTER);dc.drawText(w / 2,footerY,Graphics.FONT_XTINY,footerText(),Graphics.TEXT_JUSTIFY_CENTER);return;}var top = 32;var rowHeight = 39;var leftX = 22;var rightX = w - 40;var count = _quotes.size();if (count > _displayPageSize) {count = _displayPageSize;}for (var i = 0; i < count; i++) {drawQuote(dc,_quotes[i],leftX,top + i * rowHeight,rightX - leftX,rowHeight);}// 底部显示页码和最后刷新时间。dc.setColor(Graphics.COLOR_LT_GRAY, Graphics.COLOR_TRANSPARENT);dc.drawText(w / 2,footerY,Graphics.FONT_XTINY,footerText(),Graphics.TEXT_JUSTIFY_CENTER);}
这里没有复杂控件,只有文字、颜色和坐标。
在手表上,很多时候最可靠的 UI 不是复杂布局系统,而是清楚地知道每一个像素要放在哪里。
A 股用户习惯:
红色 = 上涨绿色 = 下跌
所以绘制价格时使用了这样的逻辑:
var isDown = change.find("-") == 0 || percent.find("-") == 0;var isFlat = percent == "0"|| percent == "0.00"|| percent == "+0.00"|| percent == "--";var color = Graphics.COLOR_RED;if (isDown) {color = Graphics.COLOR_GREEN;} else if (isFlat) {color = Graphics.COLOR_LT_GRAY;}
然后把颜色用于价格:
dc.setColor(color, Graphics.COLOR_TRANSPARENT);dc.drawText(x + width + 4,y - 4,Graphics.FONT_SMALL,price,Graphics.TEXT_JUSTIFY_RIGHT);
这段代码提醒我一个细节:
UI 不是完全中性的,它会受到用户习惯和市场文化影响。
如果做美股应用,这套颜色逻辑可能就要反过来。
手表没有键盘,所以股票管理不能照搬手机输入框。
这个项目做了两个页面:
Stock_ApiStockListView 股票列表管理Stock_ApiSymbolEditView 股票代码编辑
列表页支持:
UP / DOWN 移动选择START 编辑或新增长按 START 删除BACK 返回
编辑页把股票代码拆成两部分:
市场:sh / sz数字:6 位代码
用户逐位调整:
UP 数字 +1DOWN 数字 -1START 下一位BACK 取消
建议这里放两张截图:

图 2:Stock Settings 股票管理页面

图 3:Add Stock / Edit Stock 股票编辑页面
这种输入方式看起来慢,但它适合手表。
手表端输入的目标不是“快”,而是:
可控不误触不依赖手机
用户修改后的股票列表会保存在手表本地:
function saveSettings() as Void {var text = symbolsToText();try {// 保存到 Garmin 应用本地存储。Application.Storage.setValue(_storageKey, text);} catch (ex) {System.println("Failed to save " + _storageKey);}}
读取时:
function loadSettings() as Void {var text = _defaultSymbolsText;try {var localSaved = Application.Storage.getValue(_storageKey);if (localSaved != null && localSaved.toString() != "") {text = localSaved.toString();}} catch (ex) {System.println("Application.Storage not available, using defaults");}_symbols = parseSymbolList(text);clampPageIndex();}
这样即使退出应用,再次打开时,自选股也还在。
这一步很重要,因为它让手表端形成了一个闭环:
查看行情管理股票保存列表下次继续使用
本项目用 VS Code 和 Garmin Connect IQ SDK 调试。
编译命令类似这样:
java.exe-Xms1g`-Dfile.encoding=UTF-8`-Dapple.awt.UIElement=true`-jarmonkeybrains.jar`-obin\Stock_Api.prg`-fmonkey.jungle`-ydeveloper_key`-dfenix7_sim`-w
这里 fenix7_sim 可以用于编译。
但我在命令行推送模拟器运行时发现,运行阶段要用:
fenix7如果直接用:
fenix7_sim模拟器会报错:
Unable to load device fenix7_sim.也就是说:
编译目标:fenix7_sim模拟运行:fenix7
这是 Garmin 开发里一个容易踩的小坑。

这个应用已经能跑起来,也能显示 5 只股票行情。但如果继续打磨,还有几个方向。
第一,异步请求需要版本控制。
现在网络请求是异步的。如果用户快速翻页,旧请求可能比新请求晚返回,从而覆盖当前页面数据。
更稳妥的方式是:
每次请求生成 requestId回调时只接受最新 requestId 的结果旧请求直接忽略
第二,布局还可以更精细。
当前第 5 行已经能显示,但和底部页码距离比较近。后续可以针对 Fenix 7 的圆屏进一步微调行高、字体和底部位置。
第三,可以加入更多状态提示。
比如:
网络不可用接口无返回某只股票无行情正在刷新最后刷新失败
手表屏幕虽小,但状态反馈不能太含糊。
第四,可以扩展更多市场。
当前主要支持 A 股 sh/sz。如果后续支持港股、美股,需要重新设计代码规则、接口字段和颜色习惯。
做这个项目后,我最大的感受是:
手表应用不是手机应用的缩小版。
手机可以复杂,手表必须克制。手机可以层层跳转,手表最好一眼看懂。手机可以输入大量内容,手表最好只做必要操作。手机可以展示完整信息,手表更适合展示关键状态。
股票行情放在手表上,不应该追求“大而全”,而应该追求“刚刚好”。
对这个应用来说,“刚刚好”就是:
5 只自选股当前价格涨跌额涨跌幅最后刷新时间
它不替代手机股票软件,但它补上了一个很具体的场景:
我只是想抬腕看一眼。
这也是我觉得 Garmin Connect IQ 有意思的地方。它让开发者重新思考:当屏幕变小、输入变少、使用时间变短,什么才是真正有价值的信息?
很多时候,答案不是更多功能,而是更准确的取舍。
实战精彩推荐
汽车基础软件AutoSAR自学攻略(一)-低成本AutoSAR环境搭建
汽车基础软件AutoSAR自学攻略(二)-AutoSAR CP分层架构(1)
汽车基础软件AutoSAR自学攻略(三)-AutoSAR CP分层架构(2)
汽车基础软件AutoSAR自学攻略(四)-AutoSAR CP分层架构(3) (万字长文-配21张彩图)
系统学习AutoSAR ETAS RTA-OS嵌入式操作系统(一)
系统学习AutoSAR ETAS RTA-OS嵌入式操作系统(二)开发过程 Development Process
AutoSAR 内存管理 NVM 规范 (1)(R24-11翻译)
AutoSAR 内存管理 NVM 规范 (2)(R24-11翻译)
AutoSAR实战:如何在BSW配置看门狗Wdg Stack的监控功能 (万字长文,50张图片)
AutoSAR实战:MemStack之NVM和FEE原理介绍
AutoSAR实战:Crypto Stack 信息安全加密栈集成方法
AutoSAR实战:UDS诊断服务Communication Control (0x28)配置详解

文末福利
2.为便于技术交流,创建了AUTOSAR技术交流群,可尽情探讨AP,CP,BSW,ComStack,MemStack,OS,DDS,SOME/IP等前沿热点话题,后台回复“加群”即可加入;