Even Realities · Glasses OS · 设计评审

S300 OS — 设计与验证

系统、应用运行时、开发者体验——设计、取舍与模拟验证。

更新2026-06-15
状态可运行原型 + 实时验证
基线glasses-architecture.html — v0 设计方案
概览

三个支柱,外加一层模拟验证

本文把眼镜 OS 拆成三个设计支柱来呈现。除开发者 SDK 仍是设计稿外,其余部分均已可运行。一套贴合 AP510 芯片的模拟器用于验证设计:内存能否放进 3 MB、交互是否够快。

验证 · 模拟器 + telemetry

两个进程经建模的 BLE 链路通信;眼镜「大脑」以 wasm 运行在硬性 3 MB 上限 + CPU fuel 计量下,由真实端上图形库绘制——每次交互都换算成数字(延迟瀑布、fuel、相对上限的内存占用)。

与 v0 的核心区别

相对 v0 的关键决策

本节假设读者已了解 v0 方案:有状态的云端 App Runtime 持有应用态并下发语义 diff;手机 Bridge(带缓存 + 预测)转发;眼镜渲染受限 DSL 并执行一小组预授权本地转移,让高频交互不上行。下面只列:保留了什么、改了什么、以及为什么。

保留并验证 v0 的想法,已确认 改动 走了不同路径(出于选择或硬件) 暂未 v0 的想法,认可但未做 新增 v0 没有、本设计补上

总览对照

方面v0 方案本设计结论
视图态 / 应用态划分核心思想已实现;确认可作为整个回路的基础保留并验证
onEdge clamp/wrap/bubble本地↔应用 的边界已实现保留并验证
眼镜渲染器 = LVGL端上用 LVGLLVGL v9 跑在沙箱中保留并验证
共享 Rust core("one f")云 / 手机 / 眼镜 共用一份 core手机 + wasm 眼镜已共用;能否落到 MCU 仍待证暂时一致
增量(脏矩形)渲染v0:patch 驱动 LVGL 增量改动,而非整树重建已实现——本地移动只改变化的行;~28 ms → ~4 ms(~7×)保留并验证
App Host 位置App Runtime 在云端;手机是带缓存 + 预测的智能 Bridge手机成为 app 态的 App Host(源头);云端仅作 app 可选后端(大模型推理等),不是 App Host(见 App Host 放在手机改动
transition authorization云端为每个视图下发显式 localTransitions类型化组件自带本地能力 + on_edge;不下发表改动
DSL 词汇表预先冻结 ~12 个组件 + 10 条本地转移同一套受限、可 schema 校验的 DSL;已实现 15 个组件 + 2 条本地转移 + 3 个 Op(其余转移按需补)保留并部分实现
确定性重放 · 预测 / 预取v0 用于收敛与「快」的杠杆未探索;当前规模 请求/响应 已够,且渲染开销看起来大于链路开销暂未
wire protocol二进制(CBOR / FlatBuffers)+ diff + 压缩目标同 v0(二进制零拷贝 + 压缩);当前用 JSON + Patch(diff ✓)便于调试,二进制 / 压缩 / 通用 tree-diff 待做保留并部分实现
蓝牙传输真实 BLE 上的 L2CAP CoC / GATT(Actions ATW6095)设计相同:手机↔眼镜仍是 BLE 上的 L2CAP CoC / GATT;只是模拟器用限流替身建模,未跑真实射频保留并部分验证
多应用桌面单一全屏应用桌面合成器——最多 3 个应用平铺、眼动追踪聚焦(已实现)新增
多页与后台无工作区,也无后台运行多页桌面(≤ 4 页 / 12 应用)+ 所有应用后台常驻运行(已实现)新增
视图 · 导航 · 动作路由单一全屏应用,无应用内视图栈 / 返回导航应用内多视图 + 系统视图栈(双击返回);应用内无指针,每视图一张动作映射把动作确定落到唯一处理者(已实现)新增
第三方应用打包应用都在云端,未涉及分发沙箱 WASM 包 + 声明式 manifest,像 APK 一样安装(仅设计)新增
内存 / 显存管理无内存管理设计(显示无关;仅担忧 ~1 MB RAM)v0 没有这块,本设计做了一部分:两块双缓冲 720p 帧缓冲放 PSRAM,内部 SRAM 剩 ~640 KB;完整设计待补(见待确认问题部分实现
AI 内容生成语法约束的 DSL 生成视图就是一棵 schema 可校验的 DSL(JSON),由 App Host 下发——所以让 AI 动态生成 / 流式下发这棵 DSL 天然可行,封闭文法还顺带约束并校验 AI 的输出。OS 不亲自生成内容,但 DSL 的设计本身就为「AI 生成的 UI 直接落地」铺好了路;用不用、何时用,由应用开发者决定。天然支持

新增 的几项 v0 完全没有,展开见下文「v0 之外的新增」。

关键取舍与理由

App Host 放在手机,而非云端

v0:每会话一个有状态的云端 App Runtime(以 Phoenix-LiveView 为蓝本),手机是带缓存 + 预测的智能 Bridge(会话快照缓存、断网降级、部分乐观预测,是「边缘节点」而非哑中继)。本设计:手机即 App Host——承载应用、持有会话 / UI 态;UI 的状态回路不依赖云端、在手机本地闭环,云端只作 app 可选的网络资源(后端)。

理由:v0 自己的延迟预算里,蜂窝→云这一段是在 BLE 之上再加 ~50–150 ms。去掉它是体感延迟上最大的一项收益——应用态交互只付 BLE 往返(约一半延迟)、可离线工作、用户数据可留在手机(隐私 / 合规)。这也顺着 v0 已有的方向(把缓存与预测推向手机),把 Bridge 直接升格为 App Host。两种方案的权衡如下:

方案
Cloud App Host
(v0)
应用逻辑、数据与 AI 全在服务端:易于更新、有重算力、内容生成自然、手机只是瘦中继。 每次 app-state 交互在 BLE 之上再付蜂窝往返(~50–150 ms);离线即白屏;用户数据全流经云端(隐私 / 合规);蜂窝流量与射频耗电;依赖连接。对平台还是个两难:开发者自带云服务 → 平台不可控(质量 / 隐私 / 可用性 / 合规);平台自建云 runtime → 持续的服务器 / 算力 / 带宽成本
Phone App Host
(本设计)
app-state 只付 BLE 一跳(无蜂窝 RTT)→ 约一半延迟;可离线;用户数据可留手机;UI 不依赖蜂窝;手机轻松跑应用逻辑;平台无云成本、不依赖第三方云。 应用逻辑跑手机(一套逐平台 runtime 要做 & 分发);中心化逻辑更新更难;真正的重算力(大模型)与服务端数据仍需云——但那是 app 自己的后端,不是 App Host。
结论:App Host 就在手机——不是「云 vs 手机」的折中。云端不是另一个 App Host,而是 app 的可选后端:需要大模型推理、服务端数据时,app 自己去调云服务——就像 iOS 应用有自己的网络服务一样,由 app / 开发者决定。平台不强制云 runtime,因此既不付云成本、也不被某个不可控的第三方云绑住。

本地转移由组件类型自带,不下发逐视图的表

v0:云端在每个 ViewBundle 里下发显式 localTransitions 表,声明眼镜可本地处理哪些事件、以及如何处理(patch)。本设计:每个类型化组件自带预授权的本地行为——List 自带 focus / scroll,Text 自带 scroll——再加一条 on_edge 规则;不单独下发表。

理由:组件类型本身就蕴含其本地行为,把它编码进类型 → 线上更小(无需下发表)、不会与树脱节、眼镜端更简单(无需查表,渲染器即知)。v0 的原则被完整保留——眼镜无需业务逻辑即可处理本地交互——只是机制不同(用静态类型代替运行时表)。代价是逐实例的灵活性(App Host 无法单独为某个 list 重新授权转移),目前没有应用需要它。

v0 之外的新增

v0 假设单一全屏应用、应用都跑在云端;以下几块是它完全没有、而本设计补上的。

多应用桌面

v0 假设单一全屏应用。本设计加了一层桌面合成器:最多三个应用平铺,眼动追踪指针放大并聚焦被看的块。它是同一套视图态 / 应用态模式的第二层——桌面层管「哪个应用被聚焦」,每个应用管「哪个元素被聚焦」,所以眼动追踪与悬停放大都是本地视图态,永不往返。完整机制见 S300 OS

多桌面与后台生命周期

v0 是单一全屏应用,没有「工作区」概念,也没有后台运行。本设计支持多页桌面(每页 3 个 tile,最多 4 页 / 12 应用),所有应用后台常驻运行(聊天新消息、AI 回复等都在后台推进),隐藏页只是不渲染、不接收交互;后台应用在眼镜上只留 DSL 树,多任务几乎不占内存。详见 S300 OS · 多桌面

视图、导航与动作路由

v0 是单一全屏应用,没有应用内的视图层级与返回导航,也没回答「一个动作在没有指针的视图里交给谁」。本设计补上:应用由多个视图组成、共用一个系统视图栈confirm 进入、double-tap 返回——pop 是系统原语,应用不必实现返回);因为应用内没有眼动、手势没有空间目标,每个视图带一张动作映射,保证一个动作确定地落到唯一一个组件。详见 视图与导航动作路由

第三方应用打包

v0 的应用活在云端运行时里,没有处理外部开发者如何分发应用。把 App Host 移到手机后,这成了一个真实问题——由开发者体验设计回答(见 开发者体验):应用是带声明式 manifest 的沙箱 WASM 包,像 APK 一样安装。已设计,未实现。

内存 / 显存管理——v0 没有,本设计做了一部分

v0 没有内存管理设计——它对显示是显示无关的,只标注了 ~1 MB RAM 的担忧,没有给出内存 / 显存的分配模型。本设计在这里补了一部分:据真实芯片把两块双缓冲 720p 帧缓冲从内部 SRAM 挪到 64 MB PSRAM,内部 3 MB SRAM 因此只剩 ~640 KB 的 UI 工作集——v0 的 1 MB 担忧也化解。RAM 上限(3 MB)是模拟器强制的硬约束、已验证。

但这只是开头。完整的内存 / 显存管理设计还没做——帧缓冲的确切放置与像素格式、SRAM / PSRAM 的分配策略、PSRAM 带宽 / 延迟对渲染的影响、字体与资产从 NAND 的分页、LVGL 池与栈预算,都待补。列入待确认问题

结论:v0 的骨架——视图态 / 应用态划分、onEdge 边界、LVGL、共享 Rust core——保留。App Host 位置与 transition authorization 机制由本设计改动;DSL 词汇表沿用 v0、已实现大部分;蓝牙传输沿用 v0(部分验证);显示与内存模型据真实芯片具体化;二进制 wire protocol 与预取留待后续;cloud App Host 按需另置;AI 内容生成则由 DSL 天然支持(schema 可校验的 JSON,AI 可动态生成 / 下发),交由应用开发者按需启用。
S300 OS

S300 OS 的系统设计

本节是这套眼镜 OS 的完整设计:它如何分层、靠什么承重原理(view-state / app-state)运转、桌面与输入怎么组织、UI 用什么组件词汇(DSL)、以及渲染怎么做(全屏、增量更新)。

架构总览

S300 OS 是 server-driven-UI / 瘦客户端范式,App Host 在手机。整套系统是两层 + 中间一条 BLE 链路:

手机 = App Host

应用跑在这里、持有 app 态(见应用运行时)。把一棵受限组件树(ViewBundle)推给眼镜,同屏变化推 Patch;接收眼镜上报的事件、跑应用逻辑、回新视图。

眼镜 = 薄渲染器 + 本地 view-state

用 LVGL 把 DSL 渲染到两块 720p 帧缓冲;能本地闭环的交互(焦点 / 滚动)就地解析、就地重绘、不上链路;碰到 app 态就上报手机。

中间是 BLE——L2CAP CoC 走 diff 流、GATT 走控制 / 回告。手机承载应用逻辑与桌面合成两层,眼镜只负责渲染。

核心原理:view-state / app-state 划分

这是整套 OS 的承重设计:把交互分成两类,尽量多地在眼镜本地闭环。

  • view-state(本地)——下一帧能由设备上已有的数据 + 这个事件确定算出(焦点移动、列表 / 文本滚动、切换已下发的局部)。眼镜本地解析、本地重绘,不上链路,<100 ms。
  • app-state(上报)——需要应用逻辑或新数据(打开详情、提交、语音、跳到未下发的界面)。事件上报手机,手机回新视图,付一次往返。

判定一个交互能否本地化,用三条准则:闭包性(所需数据已在设备上)、确定性(下一帧 = f(本地态, 事件))、可重放性(手机能用同一个 f 算出同样结果)。三条都满足才本地,否则上报手机。

边界旋钮是 onEdge:本地转移撞到数据边界时——clamp(停住)/ wrap(绕回)/ bubble(降级成 app-state 上报)。「撞边 → bubble」就是 view-state 与 app-state 的边界:固件实现了它,本地处理不了的情况就自动上报。

本地处理 vs 上报

每个输入先映射成语义动作(scroll-prev / scroll-next / confirm / double-tap 等)喂给 shared::apply(),可由眼镜就地解决就本地处理、否则上报手机:

本地(视图态)

可由眼镜上已有数据解决——移动焦点、滚动列表 / 文本。沙箱改自己的树、重绘,链路上不发任何东西。逻辑亚毫秒级,唯一开销是渲染本身。

上报(应用态)

需要应用逻辑或新数据——打开详情、返回、提交、语音。事件发给手机,手机回一个新的 ViewBundle。付完整往返。

应用态变化时手机下发整个 ViewBundle,外加一层 { b, proc_us },让眼镜在 telemetry 里归因手机的处理时间。组件词汇表见下。

结合眼动的多任务桌面

OS 有一层桌面合成器:一页桌面放最多三个应用的平铺块(可多页,见下),一个眼动追踪指针(在模拟器里是鼠标)选中应用,被选中的块放大并聚焦

层级「组件」是…本地视图态(不往返)应用态(上报)
Shell(桌面)应用窗口 / 槽位眼动追踪指针 · 悬停/聚焦的应用 · 放大启动 / 关闭 / 切换应用
App(平铺块内)DSL 节点(List / Text)元素聚焦 / 滚动confirm / submit

桌面层是又一台视图态状态机,节点是应用窗口,叠在各应用的状态机之上。眼动追踪、悬停放大与聚焦都是本地桌面视图态——实时、永不往返。各应用内容仍按槽位走 ViewBundle / Patch 协议;桌面层合成各块、把手势路由给被聚焦的应用。

输入——共存

眼动追踪选应用(粗,桌面级);镜腿触控手势驱动被聚焦应用的内部视图态(细)。两个传感器都用。

开销——增量合成

合成是增量的——把脏矩形提到上一层:每个眼动追踪事件只触及变化的块,未变的块不重绘。(聚焦用描边 + 轻微放大 + 灰墨压暗表示——不是对象透明度,后者的逐块混合层开销和整屏重合成一样大。)

眼动追踪交互重绘什么估算端上耗时
眼动追踪在被聚焦应用移动(连续情形)仅光标~1 ms
被聚焦应用内容更新(push / 手势)一个块~29 ms
眼动追踪到另一个应用两个块(缩回 + 放大)~47 ms

在增量合成之前,上述每一种都要把三个块整体重合成,约 ~57 ms。连续情形(指针跟着眼睛)现在便宜约 ~50×(~1 ms),而这恰是最需要顺滑的一类。跨应用是一次离散扫视,仍要重绘两个文本密集的块(~47 ms,在 100 ms 预算内);改用扁平聚焦环叠加可让它也降到光标级,代价是失去放大。

多桌面

桌面分页,每页 3 个槽位,最多 4 页(12 个应用)。顶部一条页面切换条:眼动追踪停在上面时它高亮,此时滚动即切到相邻页;只有当前最后一页放满,才会多出一页可用。每页各自保留自己的 3 个应用,切页是瞬时的——current_desktop(当前显示第几页)是眼镜本地 view-state,切页不往返手机,和聚焦一样即时。

内存与运行实例

分页带出一个资源问题:隐藏的页,UI、状态、运行实例要不要都留着、还继续跑?划分两侧(眼镜 = 渲染端、手机 = App Host)答案不同。下表是隐藏页在两侧的处理:

隐藏页眼镜(3 MB SRAM)手机(GB 级)
已实例化的 UI无——只有可见的 3 个 tile 是 LVGL 对象不渲染
状态缓存的 DSL Node 树(每页 ~KB)完整活实例(一个 wasm Store)
执行不跑(眼镜从不跑应用逻辑)照常 tick(后台运行)
随桌面数增长否(恒定)内存:随应用数(封顶 12,便宜);CPU:随活跃应用数

眼镜侧:多任务几乎不占内存。贵的部分是 LVGL 对象(~256 KB 池、密集视图峰值 ~190 KB),只为可见页的 3 个 tile 存在。后台(非前台)应用在眼镜上只留一棵 DSL Node 树——每页几 KB,不是 LVGL 对象。所以应用再多、页数再多,眼镜的 SRAM / LVGL 池预算几乎不增长——这是对设备最关键的保证。

手机侧:所有应用后台常驻运行。每个已启动应用(跨所有页)都是活实例,不论在不在当前页都照常 tick——后台运行是特性而非负担:聊天的新消息、AI agent 的回复、实时转写、计时器、音乐,都要在你没看着时继续推进。隐藏页的应用只是收不到眼镜交互(你没在看它)。内存封顶 12 应用、不成问题;CPU / 电量随活跃应用数增长,但有上限(后续可按应用类别加后台限额)。

后台更新如何落到眼镜。后台应用产生的 Patch / ViewBundle 照常下发,眼镜把它应用到缓存的 DSL 树上,但不渲染成 LVGL——直到那一页被切到前台,才用最新的缓存树填回 tile。所以后台更新很便宜(只更新模型、不绘制),切回去看到的总是最新内容。

实现状态:多页桌面、切换条、切换与增长规则、眼镜按页缓存、所有应用后台 tick、隐藏更新「入树不绘制」——均已实现

交互动作

真实设备有两类输入:镜腿触控板 + IMU 产生的手势,以及眼动追踪。手势在眼镜上识别、归一成一组语义动作,判定含义时从不等链路。每个动作要么被系统捕捉、应用无法截获,要么路由给被聚焦应用的当前视图。

交互动作 · 谁捕捉

动作捕捉方语义与归属
眼动追踪系统决定哪个应用被聚焦(被看的块放大);应用内没有指针,手势在视图里没有空间目标
点击 + 按住
(click+hold)
系统打开上下文菜单——系统捕捉、应用不可改:它是每个应用里都在的逃生通道(返回 / 关闭 / 全屏等)。和下面应用的「按住」手势不是一回事
滚动 ↑ / ↓应用scroll-prev / scroll-next——移动焦点 / 滚动;默认绑到视图里唯一可聚焦组件
单击应用confirm——确认 / 进入下一视图;默认绑到那个可聚焦组件
双击应用double-tap——默认触发系统返回(pop),应用可改绑到别的目标
press-start
(按住开始)
应用手指按下的瞬间——时间相关交互的起点(如开始录音)
long-press
(长按)
应用按住越过时间阈值时离散触发一次(如「长按删除」);与 press-start / press-end 相互独立
press-end
(按住结束)
应用手指松开的瞬间——区间终点(如停止录音)

系统捕捉 vs 路由给应用。只有两样是系统捕捉、应用无法截获:眼动追踪(聚焦)和 click+hold(上下文菜单)——后者保证用户在任何应用里都有一条系统逃生通道,即便应用改掉了其他动作。其余动作都路由给当前视图处理(怎么路由见动作路由)。

「按住」给出三个独立动作。press-startlong-presspress-end三个不同的动作,来自同一次按住的不同时刻:按下给 press-start、按住越过阈值给一次离散的 long-press、松开给 press-end。应用订阅它要的那个(们)——录音用 press-start + press-end 框出区间,「长按一下就触发」用离散的 long-press,三者互不依赖。语音只是 press-* 的一个订阅者,没有特殊地位——长按不绑定语音

这三个都是应用动作(普通按住),别和 click+hold(先点一下再按住)混——后者是系统手势、固定打开上下文菜单。实现现状:press-start / press-end 已接线;离散的 long-press 在设计内、尚未单独下发,且代码里 Action::LongPress 目前指的是 click+hold/菜单(命名待理顺)。

视图与导航

一个应用由若干视图(View)组成,每个视图是一棵组件树。应用有一个视图栈(类似 iOS 的 NavigationStack):confirm 进入下一层(push)、double-tap 返回上一层(pop)。例如 Chat:联系人列表视图 →(点进某联系人)push 出会话视图 →(双击)pop 回联系人。

视图栈两边都存:手机是源头,眼镜是缓存。手机(App Host)持有权威的视图栈;眼镜按槽位也缓存这条栈——每个视图就是一棵已下发的 DSL 树(几 KB)。这与多桌面、后台更新是同一套机制:眼镜缓存树、本地渲染,手机在背后按需 patch。

push 由应用、pop 在眼镜本地

  • push 由应用驱动(只有应用知道点进去要载入什么):confirm 上报手机,应用算出下一个视图,手机压栈、把它作为一次 push 下发,眼镜也压进自己的缓存栈。
  • pop 在眼镜本地完成double-tap(未被改绑时)眼镜弹自己的缓存栈、立刻重渲染上一视图——不走往返(与切桌面同理);同时上报一个 {back}(fire-and-forget)。手机据此弹自己的栈、跑 on_pop;只有当上一视图的数据在期间变过,才回一条 Patch 把它刷新,否则什么都不发。

于是返回即时(本地渲染 + 可选的小 patch),不再是「往返 + 重发整棵上一视图」。应用不必实现返回。手机始终是源头:万一两边栈不一致,手机可直接下发完整视图重新对齐。

double-tap 是应用级事件,不是系统专属。应用可以在视图的动作映射里把它绑到自己的目标。但只要应用没有接管它,系统就默认捕获它执行返回(pop)——所以「后退」对应用是免费的(不写一行代码即得),需要时又能改绑成别的行为。这正是把返回做成系统原语的方式。

动作路由

应用内没有眼动追踪。眼动只在桌面层决定哪个应用被聚焦,进了应用就没有指针——一个手势在视图里没有空间目标,系统无从知道你「点的是哪个组件」。由此得到一条硬约束:同一个视图里,一个动作只能由一个组件响应。动作路由就是把每个动作确定地落到那唯一一个处理者。

每个视图一张动作映射

每个视图带一张「动作 → 目标」映射,每个动作至多一个目标。它是一张 map——「两个组件订阅同一个动作」从数据结构上就无法表达,而不是靠校验事后拦截。目标是三种之一:

  • 一个组件 key——把动作交给该组件(如 confirm → 列表、press-start/press-end → 某个录音组件);
  • 一个应用动作 id——上报给应用的 on_action,由应用自己处理;
  • 系统——交给系统默认行为(返回)。
View {
  body: <组件树>,
  actions: {                       // 至多一个目标 / 动作;省略的走默认
    confirm:       node(2),        // → 组件 2(列表)
    "press-start": node(5),        // → 组件 5 开始录音
    "press-end":   node(5),        // → 组件 5 停止录音
    "double-tap":  app("compose")  // 覆盖系统默认(不再是返回)
  }
}

默认让简单应用零配置

  • 按组件类型推默认:视图里唯一的可聚焦组件(List / Text / RunningText)自动接 scroll + confirm
  • 系统默认:未绑定时,double-tap返回(pop)
  • scroll / confirm 始终自动绑到那个唯一可聚焦组件;double-tap / press-* 可显式绑定或覆盖。常见应用因此不写 actions,需要自由度时再写。

click+hold 不进这张映射:它由系统捕捉、固定打开上下文菜单,应用改不了(见交互动作)——这保证任何应用里都有一条返回 / 关闭的逃生通道。

现状:显式动作映射 + 类型 / 系统默认均已实现(apply() 按映射或默认路由,双击默认返回可被改绑);click+hold 固定走系统上下文菜单;按住的 press-start / press-end 已接线(离散 long-press 待单独下发),语音只是 press-* 的默认订阅者。

组件 DSL 规格

DSL 是一棵类型化组件树,序列化成 JSON 下发到眼镜(serde 编码;将来可换二进制省字节),眼镜照它渲染、就地处理本地交互。它本质是一份 JSON 版的精简 HTML:节点 = 「元素」,布局 = 「盒模型 + flexbox-lite 的 CSS」,Screen 上的动作映射 = 「事件绑定」,整体收窄到一块单色 HUD 能承受的范围。共 15 个组件

三条硬约束:① 单色 4-bit 绿 HUD、样式由系统固定——DSL 不带颜色 / 字号 / 字重,只描述结构与布局;② 应用内没有指针,一个视图里至多一个可聚焦组件(见动作路由);③ 眼镜只持有 view-state(焦点 / 滚动位置),app-state(数据、逻辑)在手机。尚未落地的部分标 未实现

数据模型

顶层是 ViewBundle = version(单调版本号)+ 根节点 root。每个节点写成 { "类型名": { 字段… } },且都带一个 key: u32。一棵以 Screen 为根的树就是一个视图。下面是一条「通知」视图、手机发给眼镜的完整报文:

{
  "version": 7,
  "root": {
    "Screen": {                        // 一棵 Screen 树 = 一个视图
      "key": 1,
      "title": "NOTIFICATIONS",
      "actions": {},                   // 动作映射(见「动作路由」);留空 = 全走默认
      "body": {
        "List": {                      // 容器把别的节点嵌进来 → 一棵树
          "key": 2,
          "items": ["build passed", "battery 77%", "sarah: lunch at 1?"],
          "focus": 0,                  // 当前选中第几项(view-state,眼镜本地维护)
          "top": 0,                    // 首个可见行(view-state)
          "rows": 11,                  // 一屏显示几行(配置)
          "on_edge": "Clamp"           // 滚到底怎么办:停住(另有 Wrap / Bubble)
        }
      }
    }
  }
}

嵌套规则(内容模型 · 谁能装谁)

节点子节点
Screenbody恰好一个子节点(要放多个就装进 VStack/HStack);外加一张 actions 映射
VStack / HStackchildren任意多个节点
Boxchild恰好一个子节点
Spacer无(只在 VStack / HStack 里有意义)
其余 11 个(List / Text / Heading / …)叶子,无子节点

key一个视图内必须唯一——Patch 与增量重绘都靠它定位节点;重复或指向不存在的 key,对应的 Op 落空(不报错)。

view-state vs app-data(解释器必须分清)

字段分两类。app-data 是手机权威的内容(整树或 Patch 下发,眼镜只读、照渲);view-state 是眼镜本地拥有、由本地交互就地改、且 Patch 必须保留的那几个字段——只有三个:

view-state 字段(眼镜本地)含义
List.focus · List.top选中项、首个可见行——滚动就地改
Text.top首个可见行——滚动就地改

其余全是 app-data(items / lines / value / text / label / on / recording / rows / on_edge …)。所以一条 Patch 改 app-data(如 SetListItem 改某行文本)时,眼镜原样保留 focus / top;列表增删项时按规则把它们夹回有效范围(见增量更新)。

组件参考

15 个组件,分三组。每个都带 key: u32。「固有高度」是测量(measure)一遍给出的、布局据以排布的像素高(基准 ROW_H = 36、~28px 字体)。

容器与布局(无 view-state;把动作路由给可聚焦子节点)

组件字段(类型 · 默认)固有高度渲染 / 语义
Screenkey · title: String · actions: Map<Action,Target> = ∅ · body: Node标题栏 ≈84px + body 填满其余根 = 一个视图:顶部一行标题 + 一个可交互 body;actions动作路由
VStackkey · children: [Node] · justify = StartΣ子高 + 8px×(n−1)竖直堆叠(flex-direction:column);justify 分配富余高度
HStackkey · children: [Node] · justify = Startmax(子高)水平排列(flex-direction:row);子宽按内容估算;justify 分配富余宽度
Boxkey · margin: Insets = 0 · child: Nodemargin.top + 子高 + margin.bottom给单个子节点四周加 px 留白(仅 margin)
Spacerkey0(主轴弹性)flex-grow:1:吃掉容器主轴富余空间;多个均分

可交互(持 view-state;本地解析焦点 / 滚动)

组件字段(类型 · 默认)固有高度渲染 / 语义
Listkey · items: [String] · focus: usize · top: usize · rows: usize · on_edge: Edge可见行数×36窗口化列表。scrollfocus + 滑 top(本地);confirm 上报(带 focus);撞数据边界按 on_edgeview-state:focus·top
Textkey · lines: [String] · top: usize · rows: usize可见行数×36滚动文本。scroll 滚一行(撞底 clamp,本地)。view-state:top
RunningTextkey · lines: [String] · max: usize行数×36追加式文本流:AppendText 增长、超 max 顶部滚走;无本地滚动。Conversate 用
VoiceInputkey · label: String · value: String · recording: bool78(2 行 + 6)语音输入框。press-start/press-end 上报 → 手机 ASR → SetVoice 回填 value/recording。无本地转移

纯展示(无交互、无 view-state)

组件字段(类型 · 取值)固有高度渲染 / 语义
Headingkey · text: String44强调的分区标题
Dividerkey18水平分隔线
KeyValuekey · label: String · value: String36左标签 / 右取值一行(value 右对齐)
Progresskey · label: String · value: u8 (0–100)(有 label ? 30 : 0) + 18线性进度条
Togglekey · label: String · on: bool36开 / 关读数
Badgekey · text: String32行内小 chip
Iconkey · glyph: String28由矩形拼的图元(不用符号字体)。已实现 glyph:dot / ring / square / box / bar;其余名 → 占位方框
Imagekey · w: u16 · h: u16 · dots: [[i16;2]]ih×min(w,560)/iw矢量折线图(点采样);唯一可做大的载荷,用来探 BLE 在途开销

设计内、尚未补的组件 未实现

组件设想字段用途
Buttonkey · label · 应用动作 idconfirm 直接触发一个应用动作,免去用 List 间接选
Disclosurekey · label · open: bool · children可展开 / 收起分组——切 open 是真正的本地 view-state
Gaugekey · label · value弧形 / 圆形仪表(区别于线性 Progress
Tabskey · tabs: [String] · active标签页,本地切活动页(view-state)
Image(光栅)key · w · h · 像素 / 编码真正的位图(现仅矢量折线)

布局

DSL 里没有绝对坐标:一个组件放在哪、占多大,完全由它所在的容器和顺序决定。三个容器 + 三个布局控制就是全部规则——一套 flexbox-lite + 盒模型。

坐标与几何

整块显示 1280×720 px。一个窗口视图渲染进一个 tile(三列之一,内容宽 ≈ tile_w − 2·CPADCPAD = 14);聚焦的 tile 用全宽、未聚焦的按比例缩小(内容随之重排)。单位都是 px:基准字体 ~28px、按字数估宽 ≈ 15px/字,行高基准 ROW_H = 36。全屏模式下视图改为铺满整屏(见全屏模式)。

两遍布局算法(渲染器自己算 x/y,不依赖 LVGL flex)

  1. measure(自底向上):每个节点在给定内容宽下报出固有高度(即组件参考的「固有高度」列)。容器据此聚合——VStack 求子高之和、每两个之间加 8px;HStack 取最高的子节点、子宽用按字数的估算;Box = margin + 子高。
  2. arrange(自顶向下):容器在自己的可用空间里摆子节点——VStack 从上往下、HStack 从左往右;Spacer 吸收主轴余量(多个均分),没有 Spacer 时由 justify 分配余量;Box 把 child 按 margin 偏移。叶子节点直接画到算出的 (x, y)。

容器:决定排布方向

容器排布子节点字段 · 间距
Screen标题(顶部一条)+ 正好一个子节点body(单个)
VStack子节点竖直堆叠(从上往下,每个占满整行宽)——即 CSS flex-direction:columnchildren[] · 8px
HStack子节点水平排列(从左往右,宽度按内容估算)——即 flex-direction:rowchildren[] · 12px

VStack = 竖直堆叠(V = vertical,孩子上下排)、HStack = 水平堆叠(H = horizontal,孩子左右排)。Screen 只放一个子节点,要在一屏放多个组件就装进 VStackHStack;默认「每个组件占一行、上下排」就是把 body 设成一个 VStack

布局控制:对齐与留白

用什么作用对应 CSS
Spacer吃掉容器的剩余主轴空间:在 VStack 里把后面的节点推到底部,在 HStack 里推到最右;多个 Spacer 均分剩余空间flex-grow:1
VStack/HStackjustify主轴整体对齐:Start(默认)/ Center / End / Between。仅当容器有富余空间(如根容器铺满 tile)时生效;若含 Spacer 则让位给它justify-content
Box{ margin, child }盒模型包裹:给单个子节点四周加 px 留白(top/right/bottom/left)。它的字段是 child(单个节点)——区别于 VStack/HStackchildren[](数组);15 个组件本身不带 margin 字段margin

例:把回复框钉到底部 + 左右留白(Chat 会话视图,已实现)

Box{ margin:{left:14, right:14, bottom:12}, child:
  VStack{[
    RunningText(消息),
    Spacer,                // ← 吃掉中间的空,把下面两个推到底
    Divider,
    VoiceInput("回复")
  ]} }

渲染结果:消息贴顶、回复框贴底(中间被 Spacer 撑开),整块离 tile 左右各留 14px。没有 Spacer 时,回复框只会紧贴在消息下面、飘在屏幕中上部。

局限与未实现 未实现

可用的「CSS」目前就是:stack 方向 + Spacer(flex-grow)+ justify + Box 的 margin。「靠右 / 靠底 / 居中 / 均分」用它们已经够;以下都还没有,按真实应用需求再补:

  • 交叉轴对齐(align)——VStack 子节点现一律占满行宽、HStack 现一律顶对齐,没有「列内左 / 中 / 右」「行内顶 / 中 / 底」。
  • 尺寸约束(width / height / min / max / 比例)——尺寸全由内容定,做不了「固定宽侧栏」或「居中的窄列」。
  • padding——只有 Box 的外边距 margin,没有内边距。
  • 文本换行 / 截断——长串行为未定义(现近似按窗口裁剪,不回绕、不加省略号);HStack 子宽也只按字数估算,CJK 偏窄。
  • 容器内滚动——只有 List / Text 自带窗口;比 tile 高的 VStack 会被裁掉,做不了可滚动的长表单。

交互与事件语义

交互模型已在前面三节讲全:交互动作(动作词汇、系统 vs 应用)、动作路由(每视图一张映射 + 默认)、视图与导航(视图栈、push / pop)。这里只补一张组件级的表:一个动作落到某类可聚焦组件时,apply() 判它本地解决还是上报。

解释器 shared::apply(root, action) 先用 first_interactive 从树根下钻到第一个可聚焦节点(穿过 Screen.bodyVStack/HStack 的子节点),再按其类型裁决这个动作能否就地解决:返回 Local(就地改树、就地重绘、不上链路)或 Bubble(AppEvent)(上报手机定夺)。下表是路由给组件的动作;double-tap(系统返回)与 click+hold(上下文菜单)是系统动作、不在此表。

可聚焦节点scrollconfirm撞数据边界
List移动焦点 + 滑动窗口 → 本地上报(带 focuson_edgeclamp / wrap → 本地;bubble → 上报
Text滚动一行(clamp)→ 本地上报focus=topclamp,仍本地
RunningText不滚动 → 本地 no-op上报
仅展示 / 无可聚焦子节点全为本地 no-op(动作路由给可聚焦子节点,没有就忽略)

要点:Listfocus(选中项)与 top(首个可见行)分开——焦点移出可见窗口时只滑动 top,全在端上;只有焦点移出数据本身才触发 on_edgeconfirm 上报时随事件带上 focus,这就是「在 confirm 处回声」:手机无需盯着每一次滚动,也能在用户「点进去」的那一刻知道选中的是哪一项。

线缆格式

同一棵树既用于下行(手机 → 眼镜)也定义上行(眼镜 → 手机)。JSON 编码:枚举写成 { "变体名": { 字段… } }(无字段的单元变体直接是字符串),字符串 UTF-8。眼镜从不做树对账——手机知道改了什么、直接告知,眼镜照做。

// 下行:手机 → 眼镜(外层还裹一层信封,见下)
ViewBundle { version: u64, root: Node }            // 应用态变化:整棵树替换该槽位
Patch      { version: u64, ops: Vec<Op> }          // 同屏微更新:按 key 定位、就地改

Op = SetListItem    { key, index, text }           // 改 List 的某一行
   | InsertListItem { key, index, text }           // 往 List 插入一行(index = len 即追加)
   | RemoveListItem { key, index }                 // 删 List 的某一行
   | AppendText     { key, text }                  // 给 RunningText 追加一行(超 max 丢最旧)
   | SetVoice       { key, value, recording }      // 改 VoiceInput 的文本 / 录音态

// 上行:眼镜 → 手机
AppEvent { node_key: u32, action: Action, focus: usize }    // focus =「在 confirm 处回声」给手机
Action = ScrollPrev | ScrollNext | Confirm | DoubleTap      // 眼镜归一出的语义动作
       | LongPress | PressStart | PressEnd                  //(LongPress 系统捕捉,不上行)
Edge    = Clamp | Wrap | Bubble                    // List 焦点撞到数据边界时怎么办
Outcome = Local | Bubble(AppEvent) | Back | Menu | Ignore   // 解释器 apply() 对每个动作的裁决

实际报文外面还裹一层信封,指明槽位与意图。下行 full:{ full, slot, nav?, actions, listen, fullscreen? }——nav:"push" 表示这是一次导航 push(眼镜压栈,否则替换当前视图)、actions 是该应用的上下文菜单项、listen 表示要连续麦克风流、fullscreen 切全屏;另有 { patch, slot }{ clear: slot }。上行有 { event:{slot,action,node,focus} }(bubble)、{ back:{slot} }(系统返回)、{ action:{slot,id} }(菜单选项)、{ launch:{slot,id} }(启动器)。version 单调递增、手机每次变更 +1(眼镜目前只记录、不据此丢弃乱序补丁)。

解释器

眼镜端解释器持有以下状态、按以下流程处理输入——与它具体怎么实现无关。(本模拟器把它拆成一个薄原生层 + 一段沙箱化的 wasm「大脑」,3 MB 上限、正是将来要落到 MCU 的那部分;那是模拟器的细节。)

解释器持有的状态

  • 每个槽位一棵缓存的 DSL 树 + 一条视图栈(导航历史);栈顶 = 当前视图。
  • view-state(List.focus/topText.top)就存在树节点里,本地交互就地改。
  • 从不持有 app-state、从不主动碰链路——只渲染、只解析能本地解析的,解析不了就发信号。

两个输入源

① 下行消息(手机 → 眼镜)ViewBundle 替换 / push / pop 一个槽位的树;PatchOp 套到缓存树上、保留 view-stateclear 清空槽位。后台槽位(不在前台)照常入树、但不渲染,直到被切到前台(与多桌面同理)。

② 输入(眼动 + 手势):眼动追踪只在桌面层决定哪个 tile 被聚焦(从不上报);其余手势归一成一个语义动作,对当前视图跑 apply(),按裁决处理:

手势 → 归一成 Action → apply(当前视图, action) → Outcome:
  Local            就地改 view-state、只重绘该 tile、不上行
  Bubble(AppEvent) 上行 { event } 给手机(confirm / press / 应用绑定的动作)
  Back             弹本地视图栈、重渲染上一视图;上行 { back }(fire-and-forget)
  Menu             本地打开上下文菜单(click+hold)
  Ignore           丢弃

渲染:树 → UI 对象树(这里用 LVGL)→ 只重绘受影响的 tile(脏矩形,见增量更新)。整套裁决逻辑(apply / apply_patch + 节点类型定义)放在 shared crate,手机与眼镜共用同一份——「什么算本地」只此一处。

一次往返示例:Chat —— 滚动本地、点按拉取

Chat 的入口是一个联系人 List。手机是唯一知道「会话里有什么」的一方,所以 Confirm 正是控制权交接的点——浏览列表零往返,点进会话正好一次往返:

眼镜动作路由手机(App Host)
眼动追踪在 tile 间移动本地(静默)
列表上 SCROLL ↓本地apply()List scroll-next → focus++ 并滑动窗口;重绘该 tile,不上行
点按「sarah」(confirm上报 ▶{event:{slot,action:2,node,focus:0}}handle_eventChat::on_event push 会话视图(消息流 RunningText + 回复 VoiceInput
渲染会话◀ bundle下发该槽位的新 ViewBundle
双击(double-tap本地 pop ◀眼镜弹缓存栈、立刻重显联系人列表;上报 {back:{slot}} → 手机弹栈 + on_pop(数据变了才回 patch,Chat 不写返回代码)

focus:0 就是手机据以知道「点的是哪个联系人」而无需盯着每次滚动的字段——「在 confirm 处回声」。代码位置:gl_on_action / gl_out_*crates/glasses-wasm;手势映射与上行转发在 crates/glasseshandle_gesturehandle_event / on_eventcrates/phone

错误处理与一致性

受限、封闭的词汇让出错面很小;解释器对异常一律安全降级、绝不崩

  • 未知节点类型 / 字段缺失:JSON 反序列化失败 → 整条消息丢弃,该槽位保留旧树含义:眼镜的词汇必须 ≥ 手机所发;上新组件要先升级眼镜、再由手机下发——前向兼容靠版本协商(未做,见下)。
  • Op 指向不存在 / 重复的 key:该 Op 落空——不报错、不动其它节点。
  • 越界focus / top / index 超范围一律夹回有效区间(增删列表项后即按此夹紧);Progress.value 超 100 截断。
  • version 倒退 / 乱序:目前不校验——眼镜套用收到的任何补丁。可靠有序由传输层(模拟器里是 TCP)保证;真机 BLE 上需要的重排 / 去重 未实现
  • 两侧视图栈不一致(push / pop 漏传):手机是权威,可随时下发完整 ViewBundle 把该槽位重新对齐。

扩展与未覆盖

DSL 是个有意保持小的封闭集合:眼镜对每个节点类型、每个 Op 的处理都预先实现好,没有运行时解释。加能力 = 加一个类型 / 一个 Op(改 shared 一处,手机眼镜共享)。当前还没覆盖、标 未实现 的:

  • 组件:Button / Disclosure / Gauge / Tabs / 光栅 Image(见组件参考的未实现组)。
  • 布局原语:交叉轴对齐、尺寸约束、padding、文本换行 / 截断、容器内滚动(见布局一节的局限)。
  • 增量 Op:现有 5 个只覆盖 List / RunningText / VoiceInput。SetText / SetProgress / SetToggle / SetKeyValue 等还没有——所以多数展示组件目前要整树重发才能更新;结构性增删节点(而非改数据)也只能整树重发。
  • 样式:单色 HUD 固定,DSL 不带 per-node 颜色 / 字号 / 字重——这是有意的,不计划开放。
  • 协议健壮性:版本协商 / 前向兼容、补丁重排去重、二进制编码(现 JSON),待补。
  • 国际化:宽度按字数估算、CJK 偏窄;字体覆盖、RTL 未处理。

DSL 是一套受限、封闭、可 schema 校验的组件集——眼镜端一个递归渲染器把任意组件树变成 LVGL 对象树。下面是每个组件在绿色 HUD 上的渲染样子、规格与用途。(手机目录里有个「Components」应用可现场展示。)

NOTIFICATIONS
› build passed
battery 77%

Screen

规格:根框——一个 title + 一个 body本地:无。用途:每个应用的外框 / 平铺块。

VStack

规格:children 自上而下堆叠。本地:把手势路由给可聚焦的子节点。用途:页面布局、分区。

HStack

规格:children 自左而右排列。本地:把手势路由给可聚焦的子节点。用途:行内分组(badge、icon、kv)。

INDICATORS

Heading

规格:一行强调的 text本地:无。用途:分区标签。

now 18C cloudy3pm rain 60%6pm clear 16C

Text

规格:lines + 一个滚动窗口。本地:滚动(上 / 下)。用途:详情正文、段落。

› AGENDA  WEATHER  MUSIC

List

规格:带窗口的 items + 焦点。本地:焦点移动 / 滚动;confirm 上报。用途:菜单、通知、曲目列表。

above
below

Divider

规格:一条水平分隔线。本地:无。用途:分隔区块。

SIGNALSTRONG
BATTERY77%

KeyValue

规格:label 左、value 右。本地:无。用途:状态对、设置读数。

STORAGE  62%

Progress

规格:可选 label + value 0–100。本地:无。用途:存储、下载、播放进度。

BLUETOOTH
SILENT

Toggle

规格:label + on 布尔。本地:切换(计划中)。用途:设置开关。

NEW3LIVE

Badge

规格:一小块 text 标签。本地:无。用途:计数、状态标记。

Icon

规格:一个命名 glyph(dot · ring · square · box · bar),由矩形拼出——不用符号字体。用途:行内状态图元。

vector · point-sampled

Image

规格:矢量图,以 w·h·dots[] 表示,点描以适配宽度。本地:无。用途:图示、地图、插画——也是刻意的 BLE 开销探针:点越多 = bundle 越大 = 在途延迟越明显。

…ship the firmware fridaystandup moved to nineunder a hundred millis

RunningText

规格:带窗口的 lines[] 流,带 max 尾长。本地:无;经 AppendText op 增长。用途:实时转写、聊天记录、日志。

REPLYhold LONG-PRESS to speak

VoiceInput

规格:label · value · recording——输入框的替代。本地:无;按住把音频流给手机,ASR 填入(无需提交)。用途:回复、记录、搜索。

每个组件自带本地转移,因此眼镜知道哪些手势在端上解决(list 焦点、text 滚动)、哪些必须上报(confirm)。Op 把同屏局部更新扩展到 List 行之外:AppendText(把一行流入 RunningText)与 SetVoice(录音状态 / 已填文本)。尚未实现的扩展组件:Button、Disclosure(展开/收起)、Gauge、Tabs,以及真正的位图(光栅)Image;一个 SetProgress/SetToggle op 可让这些指示器实时动起来。

全屏模式——可选开启、应用自供视图

应用可从上下文菜单接管整块 1280×720。它要主动开启(不支持的应用不显示该项),并自供全屏视图——桌面层只改几何:选中的块铺满、另两个隐藏、去掉边框、暂停眼动追踪切换(一个应用独占屏幕)。再点一次菜单项切回。

应用窗口(平铺)全屏(整块显示)
Conversate一列约 13 行转写横跨屏幕约 20 行——同一条流,显示更多(保持连续)
Vector Image图缩放到一列宽同一份 DSL,缩放到全宽——渲染器自适应
Timer(及其他)仅窗口不提供全屏项

增量更新

「只改变化处」在两个独立层级上都成立;手机驱动的变化下两者叠加:

  • 第一层 — 增量补丁 Patch(手机 → 眼镜,省字节)。同屏变化时,手机不发整个 ViewBundle,而发一个 Patch:一小列定点操作 Op,每个用目标节点的 key 定位、只改它的数据(写法下详)。是应用逻辑直接说「改这里」,不是对两棵树做 diff——所以眼镜无需对账。更少 BLE 流量 → 更少射频、功耗、延迟。
  • 第二层 — 局部 LVGL 重绘(眼镜端,省 CPU)。眼镜收到一处变化后,只更新受影响的 LVGL 对象、只重绘它占的那块屏幕区域(脏矩形),而不是整树重建。更少 CPU / 渲染时间。

增量更新怎么写 —— PatchOp

一个 Patch = 版本号 + 一列 Op。每个 Op 用目标节点的 key 定位、只改它的数据(不动树结构);眼镜对缓存的那棵树原地套用(apply_patch),再只重绘受影响的 tile。现有五个 Op,按真实应用需求生长:

Op目标组件字段作用
SetListItemListkey · index · text改某一行的文本(如电量行刷新)
InsertListItemListkey · index · textindex 处插入一行(index = 长度即追加),其后的项下移;眼镜顺带把焦点 / 可见窗口跟着挪
RemoveListItemListkey · index删除 index 处的一行,其后的项上移;焦点 / 窗口随之收紧
AppendTextRunningTextkey · text在文本流末尾追加一行;超过 max 时顶部最旧的滚走(实时转写 / 日志)
SetVoiceVoiceInputkey · value · recording改录音态与已填文本(ASR 结果回填)

写法和视图一样是纯 JSON。下面这条 Patch 把通知列表(key = 2)的第 2 行改掉——整条报文约 100 B(对比整棵 ViewBundle 的 ~600 B):

{
  "version": 8,                                   // 整条 UI 的单调版本号,手机每次变更 +1
  "ops": [
    { "SetListItem": { "key": 2, "index": 1, "text": "battery 64%" } }
  ]
}

每个 Op 写成 { "变体名": { 字段… } }(与节点同款);一条 Patch 可带多个 Op,按顺序套用。在链路上外面再裹一层 slot,指明改哪个槽位。边界:Op 改的是某个节点的数据——也包括像 List.items 这种向量(增删列表项就是上面的 InsertListItem / RemoveListItem),但不改树的结构:增删组件节点、换布局才需要发整棵 ViewBundle。需要新能力时按组件加新 Op(如 SetProgress / SetToggle 让指示器实时动);这是个有意保持小的封闭集合,眼镜对每种 Op 的处理都预先实现好,无需运行时解释。

变化来源第一层 · 下行 Patch第二层 · LVGL 局部
本地转移(聚焦 / 滚动)不适用——从不经手机已做  ~28 ms → ~4 ms(~7×)
手机驱动更新(同屏)已做 推一个 Patch——~100 B vs ~600 B 整视图已做 重写一行文本——~1.5 ms / ~370 k fuel
手机驱动换屏(打开 / 返回)整视图——确实是新屏,不发 Patch整树重建(~28 ms)——每次换屏一次
两者如何叠加——已实现并测量

手机把实时「电量 %」更新作为 Patch 下发(第一层,~100 B),该 op 精确告诉眼镜要重写哪一行(第二层,~1.5 ms / ~370 k fuel)——对比整个 ViewBundle(~600 B)+ 整树重建(~28 ms / ~7 M)。本地转移天然就有第二层(状态机知道什么变了 → 一次焦点移动 ~4 ms)。换屏仍发整视图,这是对的:那是新屏,不是增量。

应用运行时

应用运行时——手机端的 WASM 应用容器

这一节是给手机应用平台团队的架构设计。应用放在手机(而非云端),手机就是应用的 App Host——它承担三件事:

  • 持有每个应用的状态 / 数据。应用的数据与业务逻辑都在这里,「现在显示什么、用户操作后变成什么」由它决定;眼镜本地处理不了的事件上报给它,由它算出下一个视图。
  • runtime 容器。把应用的 wasm 加载进各自独立的 wasmtime 实例——沙箱隔离、内存硬上限、fuel 计量。
  • 生命周期管理。安装 / 启动 / 挂起 / 恢复 / 关闭;所有应用后台 tick(隐藏页只是不渲染、不接收交互)。

下面是完整方案——当前以树内注册的 Rust 模块跑通,目标是把每个应用做成沙箱 WASM 容器。

手机即应用容器

应用编译成一个 WebAssembly 模块。手机运行时把每个已安装应用加载进它自己的 wasmtime 实例——独立线性内存、逐回调的 fuel 预算、除我们交给它的 import 外没有任何 capability。这正是眼镜大脑已经在用的方式:复用一个验证过的沙箱,而非另造一个。

.glsapp 包可分发产物
app.wasm
manifest.toml
assets/ · 签名
安装即注册
手机运行时每应用一个 wasmtime Store
mem cap · fuel · 仅 capability imports
host:storage · timers · ASR · net(gated)
App Host
眼镜只渲染
渲 DSL → LVGL
本地 view-state · 3 MB
受限端
关键边界

应用跑在手机,绝不跑在眼镜上。眼镜受内存与 CPU 约束(3 MB SRAM),只渲染 DSL 并解析本地视图态。手机有余量托管许多沙箱模块。这条划分已经成立。

隔离开箱即用

wasmtime 提供内存隔离、逐次调用的 fuel 上限(失控应用卡不死手机)、以及不 import 就没有任何 syscall。

任意源语言

任何能编到 wasm32 的——Rust、C/C++、TinyGo、AssemblyScript、Zig。ABI 与语言无关。

同样的类型与渲染器

DSL(shared)与端上 LVGL 渲染器不变。应用只产出 Node/Op——它画不了任意像素。

容器 ABI:模块 ⇄ 宿主

契约对应今天的 trait:每个方法一个 wasm 函数,DSL 序列化为字节经一块共享缓冲传递——正是眼镜大脑已在用的 inbox_ptr / 长度 模式。宿主驱动模块;模块只能经被授予的 import 回调。

导出(应用实现)

app_launch()→ ViewBundle
app_tick()→ Ops
app_on_action(id)→ Ops
app_on_voice(phase)→ Ops
app_on_event(code, focus)→ ViewBundle?
app_set_fullscreen(on)→ ViewBundle
app_inbox_ptr() / _cap()共享缓冲

导入(宿主按 capability 授予)

host_log(ptr,len)始终
host_now() → u64始终
host_storage_get/setstorage
host_asr_request()voice_asr
host_http(req)network
host_timer(ms)timers

字节经共享缓冲往返(把参数写到 app_inbox_ptr()、调导出、读返回的指针 / 长度)——边界两侧不解引用指针,模块保持完全沙箱化。这里用 Node/Op 的二进制编码代替 JSON 以减小体积,下游的 BLE 预算也随之收紧。

安装、生命周期与治理

生命周期:installedrunningsuspendedclosed。启动把模块实例化进某槽位的 Store;挂起停 tick、切回即时恢复;关闭丢弃 Store、回收内存;持久状态靠 storage capability 存活。多桌面下所有应用后台常驻 tick,隐藏页只是不渲染、不接收交互(见多桌面)。手机的 App Manager 界面(独立 URL,:8081)是安装 / 启动 / 挂起 / 恢复 / 关闭 的入口。

安装

商店或侧载 → 校验签名 + manifest schema + min_os → 显示capability 提示(被授予的 import)→ 注册进目录。手机的 App Manager 界面已建模 安装 / 启动 / 挂起 / 恢复 / 关闭——只是把目录来源从写死列表换成已安装的包。

生命周期

启动把模块实例化进某槽位的 Store;挂起 / 恢复 控制它的 tick();关闭丢弃 Store(回收内存)。持久状态靠 storage capability 存活,而非实例。

资源治理

逐应用的线性内存上限、逐回调的 fuel 上限(坏应用卡住自己,不卡 OS)、以及一项包体积预算——大视图带来真实的 BLE 延迟,telemetry 把它显示出来(9 KB 图 ≈ 在途 150 ms vs 小 patch ≈ 25 ms)。

兼容性

DSL 词汇表与 ABI 都带版本。manifest 的 min_os 加宿主的 capability 列表,让 OS 能拒绝用了它不支持的节点或 import 的模块——前后向都安全。

信任模型。签名给来源;capability 给最小权限;wasmtime 给隔离。一个经审核、已签名、声明了 capability 集的包,运行前可审计、运行时被隔离——这正是应用商店需要的两条性质。

上行通道:菜单 / 语音 / 导航 / 启动

这些应用交互共用一套上行机制——眼镜 → 手机的上行路径,与既有的下行视图并存:

眼镜上的触发上行消息手机处理
上下文菜单选应用动作项(click+hold 打开){action:{slot,id}}app.on_action(id)Op[](patch)
应用启动器选项(空槽位 click+hold 打开){launch:{slot,id}}运行时把应用启动进该槽位
按住(hold)录音 → press-start / press-end{event:{slot,action,…}}路由到 VoiceInputapp.on_voice → ASR → SetVoice
confirm 上抛(带 focus 索引){event:{slot,action,node,focus}}app.on_event → push 新视图
double-tap → 系统返回(眼镜本地 pop){back:{slot}}弹自己的栈 + on_pop;仅当上一视图数据变过才回一条 patch(应用不必处理)

上下文菜单与启动器

click+hold 运行中的应用,在其平铺块上打开一个列表:← Dismiss(安全默认——退出)、系统 Close(以及 Full Screen,若应用开启),然后是应用自己的动作 → on_action。click+hold 槽位则打开应用启动器,列出已安装应用以开进该槽位。

语音输入

输入框的替代:按住录音,松开发送。眼镜把按住过程流给手机,手机跑 ASR(这里是个小替身)并填入 VoiceInput——无需提交按钮。Conversate 以同样方式把实时转写流入一个 RunningText

逐应用导航

上报的 confirm 携带被聚焦的索引给应用的 on_event,它可返回一整个新视图(push);double-tap 是系统返回(pop),应用不必处理——于是应用在同一协议上于视图间导航(联系人 → 会话,图片 → 下一张)。

模拟环境 vs 真实手机

模拟器跑在 Mac 上、用来验证设计(内存、延迟、交互),并非真实手机运行时本身——也不是开发手机应用的地方。共用、两边一致的那份——shared DSL、App trait、LVGL 渲染、view-state / app-state 划分——不在此列;下面只标当前模拟环境与真实手机实现的区别。

方面当前模拟器(Mac)真实手机
应用形态树内注册的 Rust 模块、直接调用(编译进进程、全信任)经 wasmtime 加载的沙箱 WASM 包、capability 门控(容器化已设计,模拟器尚未跑 wasm 容器)
宿主Mac 上的一个 host 进程iOS / Android app(原生 BLE、后台保活、配对)
语音 / ASRMac 端 ASR 替身真实 ASR 模型(仍在 App Host 侧,非眼镜端)
目录 / 安装写死的 catalog(make(id)已安装 .glsapp 包的注册表 + 安装 / 授权流程
手机↔眼镜链路建模的限流替身(crates/link真实 BLE:L2CAP CoC / GATT(Actions ATW6095)
示例应用

我们构建的应用

十个应用端到端地检验组件、OS 与运行时——每个专攻框架的不同部分。

应用演示什么
Conversate实时转写——由 AppendText 流入的 RunningText;全屏「杀手级」视图
Chat导航(联系人 → 会话,经 on_event)· VoiceInput 撰写 · 一个「Echo Bot」在 tick 上流入模拟来信
Vector ImageImage 组件 + 刻意的延迟探针:点击循环 小 / 中 / 大(825 B → 7.5 KB;在途 37 ms → 152 ms)
Voice Note最简的 VoiceInput 听写流程
Components整套 DSL 词汇表的实时画廊
Notifications · Agenda · Weather · Music · Timer日常界面——列表、键值、进度、实时电量、逐应用菜单动作
方向。这些应用今天编译进 OS。开发者体验 一节给出平台:同一个 trait,打包成带声明式 manifest 的沙箱 WASM 模块,像 APK 一样安装——于是第三方无需我们的源码树即可发布应用。
开发者体验

开发者体验——构建与分发应用

开发者体验有三部分。组件 DSL构建面——一套封闭、可 schema 校验的词汇表,应用用它拼出 UI。这套双进程模拟器是实时开发回路——跑一个应用,在查看器里看它的延迟、fuel 与内存。缺的一块是打包与分发:今天示例应用编译进 OS;为让别人能发布应用,我们设计了一个平台。下面是这个平台的完整设计——设计稿,尚未实现

论点:已经在 wasmtime 下跑着一个带硬性内存上限 + fuel 计量的 wasm 模块——那就是眼镜大脑。把同一套机制用到手机上的应用模块,只需极少的新基础设施就能得到一个真正的平台。

要做的四样东西

要成为一个真正的平台——让第三方无需我们的源码树即可构建应用、以自包含产物分发、用户像装 APK 一样安装(选取、授权、运行)——需要下面四样东西。均未开发

一个包

可分发、带版本、已签名的产物——代码 + manifest + 资源——无需重建 OS 即可安装。

一个运行时

安全加载不受信第三方代码的沙箱,带硬性内存 / CPU 上限,且无任何环境 capability。

一个 SDK

稳定的 API 面 + 一个宏,把开发者的 handler 变成可加载模块,隐藏线上 ABI。

工具链

一个 CLI 负责脚手架、构建、校验、模拟、打包——以这套双进程模拟器作为开发回路。

应用接口:一个 Rust trait

应用的接口就是一个 Rust trait:launch() 返回初始视图(受限 DSL 树 Node);tick() 与事件钩子返回局部更新(Op)或新视图。眼镜渲染 DSL 并在本地解析视图态;只有应用态事件才上报到手机上的这段代码。这个 trait 小、声明式,已覆盖视图、更新、菜单、语音、导航与全屏——SDK 原样沿用它

pub trait App: Send {
    fn manifest(&self) -> Manifest;                       // id、名称、图标
    fn launch(&mut self) -> Node;                          // 初始视图(DSL)
    fn tick(&mut self) -> Vec<Op> { vec![] }                // 周期性局部更新
    fn menu_actions(&self) -> Vec<(&str, &str)> { vec![] }   // 上下文菜单项
    fn on_action(&mut self, id: &str) -> Vec<Op> { vec![] } // 菜单项被选中
    fn on_voice(&mut self, phase: &str) -> Vec<Op> { vec![] }// 长按听写 → ASR
    fn on_event(&mut self, code: u32, focus: usize) -> Option<Node> { None } // confirm/back
    fn supports_fullscreen(&self) -> bool { false }        // 选择支持全屏
    fn set_fullscreen(&mut self, on: bool) -> Node { .. }   // 全屏视图
}

单有这个 trait 还不够成为平台:它要我们的源码才能编译、以宿主全信任运行、没有包与版本、目录在编译期写死——这正是上面四样东西要解决的。

语言与运行时:WASM 作契约,Rust 作一等 SDK

分开两个问题。包的载荷永远是 WebAssembly——那是安全与可移植的边界。SDK 是其上的开发者层,今天是 Rust 优先,因为代码库、DSL 类型与最好的 wasm 工具链都是 Rust。其他语言的 SDK 日后可叠在同一套 ABI 上。

选项沙箱开发体验结论
WASM 模块,Rust 优先 SDKwasmtime——强,且已在用写同一个 trait + 一个宏;cargo build 到 wasm推荐 复用我们已有的一切
WASM 模块,任意语言相同较低(手写 ABI,或等各语言 SDK)已支持,后续 同 ABI,SDK 按需跟进
内嵌脚本(JS / Lua)需要独立的解释器沙箱对 web 开发者高,但弱类型未来一层 编到 wasm,或把解释器作为 wasm 宿主
原生动态库(.so / .dylib)无——宿主全信任熟悉否决 无隔离;对第三方不安全

关键性质:应用只能做它的 import 允许的事。没有 host_net import 的模块根本够不到网络——capability 是结构性的,不是策略。AssemblyScript / JS 支持对生态很有价值、在路线图上,但它走同一套 ABI,是叠加而非分叉。

应用包 .glsapp 与 manifest

一个包是已签名的归档。manifest 是声明式、可检视的——OS 据它列出应用、提示授权、构建上下文菜单,这些都先于模块执行。代码只在安装 + 授权之后才运行。

com.acme.chat-1.2.0.glsapp
├── manifest.toml      # 身份、capability、菜单、全屏——无需运行代码即可读取
├── app.wasm           # 编译后的模块(唯一可执行部分)
├── assets/            # 图标、矢量图、子集字体(由 DSL 寻址)
└── SIGNATURE          # 开发者密钥 → 完整性 + 来源
[app]
id      = "com.acme.chat"      # 反向 DNS,全局唯一
name    = "Chat"
version = "1.2.0"              # semver;商店与 OS 据此控制升级
icon    = "assets/icon.svg"
min_os  = "1.0"               # 宿主拒绝比自己更新的模块

[capabilities]                 # 模块获得的全部 capability;安装时提示授权
storage   = true               #   持久化键值存储,按应用隔离
voice_asr = true               #   可请求听写(驱动 VoiceInput)
network   = ["api.acme.com"]   #   宿主代理出网,仅允许列表
timers    = true               #   周期性 tick() 唤醒

[[menu]]                        # 上下文菜单项——静态声明
id = "mark_read"
label = "Mark all read"
[[menu]]
id = "mute"
label = "Mute 1 hour"

[fullscreen]                    # 选择支持全屏
supported = true
静态 vs 动态。身份、capability、菜单项、是否支持全屏,是 manifest 里的静态事实;视图、更新、动作处理是动态的,由模块运行时产出。OS 靠这些静态信息保持低开销且安全:无需运行应用就知道它能做什么。上下文菜单项因此从运行时调用变成静态声明(系统始终拥有 CloseFull Screen 开关,应用贡献其余项,选中后调 app_on_action(id));全屏同样靠 [fullscreen] supported = true 让桌面层提供开关,选中时宿主调 app_set_fullscreen(true),应用返回为整块 1280×720 设计的视图。

SDK:你的 handler 就是应用

Rust SDK 的要点:开发者写的还是今天那个 trait,一个宏生成 wasm 导出、缓冲管线与(反)序列化,ABI 对开发者不可见。

use gls_sdk::*;                       // SDK crate:App、Node、Op、host::*

struct Chat { open: Option<usize>, msgs: Vec<String> }

impl App for Chat {
    fn launch(&mut self) -> Node {
        Screen("CHAT", List(contacts()))         // DSL 构建器
    }
    fn on_event(&mut self, code: u32, focus: usize) -> Option<Node> {
        if code == CONFIRM { self.open = Some(focus); Some(self.conversation()) } else { None }
    }
    fn on_action(&mut self, id: &str) -> Vec<Op> {
        if id == "mark_read" { host::storage::set("unread", "0"); }   // 一项已授予的 capability
        vec![]
    }
    fn supports_fullscreen(&self) -> bool { true }
    fn set_fullscreen(&mut self, on: bool) -> Node { self.view(on) }
}

register_app!(Chat);                  // ← 生成 app_launch / app_on_event / … 等 wasm 导出

SDK 附带 shared DSL 类型、顺手的构建器(ScreenListRunningTextVoiceInput…)以及 capability import 的薄封装(host::storagehost::asr)。C / AssemblyScript 的 SDK 会在同一套 ABI 上暴露相同形状。

工具链 glsdk CLI——模拟器即开发回路

开发体验最大的一块已经就绪:这套双进程模拟器(手机运行时 + 眼镜查看器 + 实时 telemetry)就是 glsdk sim。开发者无需硬件即可在真实 LVGL 渲染上看到自己的应用、驱动手势,并观察每次点击的延迟、BLE 字节、内存与 fuel。

命令做什么
glsdk new myapp脚手架一个 Rust crate:App 桩、一个 manifest.toml、一个示例视图。
glsdk buildcargo build --target wasm32 + 把 wasm + manifest + 资源打包成 .glsapp
glsdk validate静态检查:manifest schema、必需导出是否齐全、DSL 节点是否在允许词汇表内、capability 合理性、包体积预算。
glsdk sim把模块加载进一个模拟器槽位,打开眼镜查看器 + telemetry——实时开发回路。
glsdk package / sign产出已签名、可分发的产物。

因为眼镜大脑与应用共用同一套沙箱模型,validate 能精确测量将要发布的东西:每个视图的渲染 fuel、每个 bundle 的字节(BLE 在途时间)、以及模块内存峰值。

验证 · 模拟器

一套贴合真实硬件的眼镜模拟器

两个协作进程对照真实硅片建模眼镜:手机持有应用态并下发视图;眼镜大脑以沙箱 wasm 运行在芯片的真实约束下,用端上图形库绘制。本节先给整体流水线,再讲两件最关键的事——硬件约束如何做进模拟、以及这套模拟的限制;最后是它建模的设备与假定参数。

流水线

手机持有应用态并下发视图;眼镜在本地判断一个输入能否端上解决、还是必须上报。眼镜大脑编译成 wasm32 跑在 wasmtime 里,于是 RAM 与 CPU 都对照硬上限测量——这是同一份注定要上 MCU 的 no_std Rust。

手机App Host
app runtime
sample app
TCP :7878
持有应用态、下发视图
glasses = AP510wasm 沙箱 · 3 MB cap · fuel 计量
decode → shared::apply() → 本地?上报?
LVGL v9 → 2× 720p framebuffers(render + display)
no_std Rust core · framebuffers 在 PSRAM(非 SRAM)
我们测量的 MCU
浏览器眼镜端模拟 UI(开发侧)
<canvas> 显示眼镜画面
+ 按钮(代替触控 / 眼动追踪)
+ telemetry(遥测)
看到眼镜画面 + 输入 + 遥测

视图态交互(聚焦、滚动)从不过链路——它们在沙箱里解决并本地重绘。只有应用态交互(打开、返回、提交、语音)才上报手机。

界面一瞥

模拟器左半边是真实尺寸的 1280×720 绿色 HUD(单绿 #3DFA44、4-bit),下面是代替镜腿触控 / IMU 的手势按钮;鼠标移到屏上 = 眼动追踪。下图是多应用桌面(三个平铺块,中间为被聚焦、放大聚焦的应用)。

Glasses Display · 1280×720 · 4-bit green
NOTIFICATIONS› build passed
battery 77%
standup 9:00
› CONVERSATE…ship the firmware friday
standup moved to nine
under a hundred millis
WEATHERnow 18C cloudy
3pm rain 60%
SCROLL ↑ SCROLL ↓ SINGLE TAP DOUBLE TAP HOLD · VOICEL TAP + LONGH
connected · 本地闭环 83%

真实运行界面的 HTML 复刻(非截图)。

如何做到硬件级模拟

「对照硬件」靠三个机制,把三种最关键的硬件约束变成可强制、可量化的指标:

内存:wasm 3 MB 硬上限

眼镜大脑编译成 wasm32 跑在 wasmtime 里,用 StoreLimits 把线性内存钉死在 3 MB(= AP510 内部 SRAM);涨过即 trap(真实 OOM)。所以「放不放得下」是被硬性检验的,不是纸面估算。

CPU:数指令 × 主频估时

wasmtime 的 fuel 精确计数一次渲染执行了多少条 wasm 指令(确定性的「工作量」);再除以假定的设备速度(M55 @ ~250 MHz、~1 指令/周期)得到端上估算毫秒。它不计宿主墙钟时间,在任何机器上结果一致

链路:自定义模块模拟蓝牙限速

crates/link 是一个限流、计量的 BLE 替身:按连接间隔、带宽、单程延迟、MTU,给每次传输算出包数与在途时间。于是 view-state(本地)与 app-state(往返)的链路代价都是被建模、可量化的。

这套模拟的限制

同样要讲清它做不到什么——三条最重要的:

CPU 速度只能估、不是测

fuel→ms 的除数是假定的(M55 主频、~1 指令/周期),没用真实 AP510 校准。所以绝对 ms 约 ±2–3×;可信的是相对比值(优化前后、A vs B),不是毫秒承诺。

无法模拟 510 上 GPU / CPU 的拆分

AP510 有一个 250 MHz GPU 做光栅化(填充 / 混合 / 缩放)。我们的沙箱用纯软件渲染 LVGL,GPU 本该做的活被记到了 CPU fuel 上 → 渲染时间是软件上界,对绘制密集操作(整屏合成、动画放大)高估最多。

难以刻画 RAM 与 PSRAM 的关系

真机里帧缓冲在 PSRAM、工作集在 SRAM,两者带宽 / 延迟不同。我们在一块扁平的 wasm 线性内存里建模,无法真实区分 SRAM↔PSRAM 的访问差异(PSRAM 较慢会加一些渲染 / 扫出延迟,需在硬件上测)。

Crate 结构

crate目标职责
sharedno_std协议(DSL NodeViewBundleAppEvent)+ 视图态状态机 apply()——那个「one f」,注定要上设备。
glasses-lvglhost + wasm把 DSL 映射到真实 LVGL v9(Even 的 lvgl-sys-v9 分支)。渲染到帧缓冲。
glasses-wasmwasm32-wasip1眼镜大脑作为沙箱模块(LVGL + 状态机),带一个宿主驱动的小 C-ABI。
glasseshost在 wasmtime 里跑 wasm 模块(3 MB 上限 + fuel)、链路客户端、查看器与全部 telemetry。
phonehostApp Host + 一个示例「notifications」应用。
linkhost限流、计量的 BLE 替身。

已验证 / 待办

能力状态
由真实端上图形库(LVGL v9)绘制的交互 UI可用
多应用桌面 + 眼动追踪指针(桌面合成器)可用
本地处理 vs 往返手机,在眼镜上判定可用
跑在芯片 3 MB 内存预算内,CPU 计量可用
实时 telemetry / 开发者视图可用
本地移动的增量(脏矩形)渲染可用
应用运行时与生命周期 + 手机安装 / 启动界面可用
完整手势集 · 上下文菜单(含 dismiss)· 语音听写 · 逐应用导航可用
可选全屏模式,应用自供视图可用
从眼镜启动应用(空槽启动器)可用
应用平台 / SDK 设计(应用即沙箱 WASM 包)已设计 — 见 开发者体验
在真实 Cortex-M55 硬件上验证(精确计时)暂未
真实 ASR 模型(当前为手机 / Mac 端替身;ASR 一直在 App Host 侧,不在眼镜端)暂未
二进制 wire protocol(替换当前 JSON)暂未

设备究竟是什么

功能器件对模拟器要紧的点
MCUAmbiq AP510(Apollo510)ARM Cortex-M55 + Helium MVE,~250 MHz;3 MB 内部 SRAM
PSRAMAP Memory APS51264 MB 外部——也可放显示缓冲 / 大资源
NANDGigaDevice 512 Mb64 MB——代码、字体(含 CJK)、资源(非 RAM)
BLEActions ATW6095手机↔眼镜链路是经独立控制器的 BLE
显示1280×720 面板单绿 #3DFA444-bit 压缩(16 级)
输入Azoteq 触控 · TDK IMU · Syntiant VAD触控 + 头部手势 → 语义事件;语音 → 应用态
帧缓冲放 PSRAM,腾出内部 SRAM

显示需要两整块 720p 帧缓冲(一块绘入、一块扫出——双缓冲)。约 0.9–1.8 MB,会吃掉 3 MB 内部 SRAM 的大头,所以模拟器把它们放进 64 MB 外部 PSRAM(我们的假设,待固件确认)。内部 SRAM 于是只装 UI 工作集——组件树 + LVGL 的池(几十 KB)。权衡:PSRAM 访问比 SRAM 慢,会增加一些渲染 / 扫出延迟,值得在硬件上测。

位置装什么大小
PSRAM · 64 MB2× 720p 帧缓冲(双缓冲)L8 下 ~1.8 MB · 4-bit 下 ~0.9 MB
internal SRAM · 3 MBUI 工作集:组件树 + LVGL 池 + 栈共 ~640 KB(LVGL 池峰值 11 KB → 建议 ~32 KB)
NAND · 64 MB代码、字体(含 CJK)、资源按页换入;非 RAM

假定的参数

模拟器据以下参数对照硬件测量;完整逐参数理由见 docs/ASSUMPTIONS.md(参数的权威出处,与代码同步)。

已确认 来自 BOM / 固件 假设 为模拟器选定 待确认 需固件 实测 模拟器输出
参数取值标记
内部 SRAM(RAM 上限)3 MB已确认
渲染库LVGL v9(Even 的 lvgl-sys-v9 分支)——假定为量产渲染器已确认
显示格式绿 #3DFA44,4-bit 压缩,1280×720假设
帧缓冲64 MB PSRAM 中 2× 整块 720p(双缓冲)假设
CPU 估算吞吐~250 M 指令/秒(M55 @ 250 MHz,~1 fuel/周期)假设
BLE:连接间隔 / 带宽 / 延迟 / MTU30 ms / 60 KB/s / 8 ms / 244 B假设
wire encodingJSON,完整 ViewBundle(暂无 diff / 二进制)假设
wasm shadow stack128 KB(默认 1 MB 会浪费预算)待确认
LVGL LV_MEM_SIZE设 256 KB → 峰值 11 KB → 建议 ~32 KB实测
验证 · telemetry

实时开发者查看器

查看器相当于眼镜的浏览器 DevTools——每次交互都换算成数字。本节也讲它已经测到了什么。

如何看 telemetry

布局:左边是实时 1280×720 显示,右边是一摞面板——每个信号一块。逐一说明怎么读。

下图是右侧面板的 HTML 复刻(非截图):事件日志(每行一次交互 + 四段延迟瀑布)、CPU(fuel → 估时)、内存(对 3 MB)、BLE 链路。

Event log42 events · 35 local / 7 remote · ↑1.8 KB ↓6.4 KB
8 scrollLOCAL4 ms
7 gaze→ConversateGAZE1 ms
6 battery %PUSH12 ms · ↓100 B
5 open chatREMOTE84 ms · ↑42 ↓0.6K
BLE-outphoneBLE-inglasses-render
CPU · est. from wasm fuelassuming 250 M inst/s
last render986 k fuel
est. render time(software)≈ 4 ms
vs 100 ms「instant」budget
软件光栅化——250 MHz GPU 未模拟。
Memory · AP510framebuffers in PSRAM
internal SRAM · working set640 / 3072 KB
PSRAM · 2× 720p framebuffers~1.8 MB
LVGL pool used / peak11 / 256 KB
BLE link · simulated60 kB/s · MTU 244 B
speed60 kB/s
interval30 ms
latency8 ms
MTU244 B
transmissions · phone → glasses
12.4 sPATCH100 B · 1 pkt25 ms
11.9 sVIEW600 B · 2 pkt37 ms

真实 telemetry 面板的 HTML 复刻(非截图);数值用代表性样例。

显示

设备面板按真实尺寸——单绿 #3DFA44,4-bit(16 级)。鼠标移过去 = 眼动追踪:被聚焦的应用放大并聚焦。按钮 / 方向键发送被聚焦应用的手势。浮动 telemetry 面板可收起(右上角),以便看到整块显示。

事件日志——每次交互一行

最新在上,可滚动、有上限的历史。点任一行可内联展开其 DSL——当时过链路的确切 ViewBundle / Patch JSON;本地行会标注未发送任何东西。kind 告诉你工作发生在哪

kind是什么代价
GAZE眼动追踪跨到另一个应用(焦点变化)仅渲染——不走链路
LOCAL被聚焦应用在端上闭环的手势(聚焦 / 滚动)仅渲染——不走链路
PUSH手机把内容变化作为局部 Patch 下发BLE-in + 渲染
REMOTE上报到 App Host 并往返的手势BLE-out + 手机 + BLE-in + 渲染

每行的延迟瀑布把体感延迟拆成四段——BLE-out · 手机 · BLE-in · 眼镜渲染——一眼看出某次交互是链路受限还是渲染受限。GAZE 或 LOCAL 行是纯渲染(一段绿);PUSH 在它前面加一段青色 BLE-in。

CPU · 内存 · BLE 链路

  • CPU——最近一次渲染的 wasm fuel → 估算的端上渲染时间,对照 100 ms「即时」预算绘出。fuel 是什么、ms 能信到几分,是关键,见下。
  • 内存——wasm 线性 RAM 对照 3 MB SRAM 预算、两块 720p 帧缓冲(在 PSRAM)、以及 LVGL 池的已用 / 峰值(据此建议 LV_MEM_SIZE)。
  • BLE 链路——链路的限速(带宽 · 连接间隔 · 单程延迟 · ATT MTU)与一份传输日志:每次突发的时间、载荷大小、BLE 包数(⌈(bytes+4)/MTU⌉)与建模在途时间。一个 449 B 的整应用视图是 2 包;一个局部 patch 是一个 80 B 包。

读延迟数字——以及为什么重要

这是算力估算,不是秒表

渲染时间是 fuel ÷ 假定设备速度fuel 是一次渲染执行的 wasm 指令的确定性计数——对工作量的真实度量;除数假定 Cortex-M55 在 ~250 MHz 下约每周期 1 条指令。它刻意不计宿主 Mac 的墙钟时间,所以这个数 在任何机器上都一样——但它是设备的模型,不是对设备的测量。

这是模拟器价值的核心:据此能给方案排序、找出昂贵操作——脏矩形、局部更新、增量合成都在这里表现为 fuel 下降。但要知道信什么:

可信需谨慎
相对比较——fuel 比值(焦点变化 vs 光标移动;优化前后)与硬件无关、稳健。绝对 ms——大约 ±2–3×:只算 CPU,尚未对真实硅片校准。
RAM——3 MB 上限是真实、强制的限制(模块涨过它会 trap),不是估算。它忽略了什么——首先是片上 250 MHz GPU,它在硬件里做缩放 / 矢量 blit(所以动画放大在端上远比 CPU 数字便宜),以及 Helium/MVE SIMD。链路管道现已知晓且不是瓶颈:PSRAM ~500 MB/s(~1–2 ms/帧)、显示发送 ~768 Mbps(~5 ms/帧,~200 fps 余量)。

所以把延迟当作序数信号,而非毫秒承诺。要做成真正的延迟模拟:用真实 AP510 上测得的一次渲染校准 fuel/秒、计入 250 MHz GPU 绘制路径(最大的缺口——CPU 数字是软件上界,所以像动画放大这种 GPU 加速的工作在端上便宜得多),最终走到更高保真度的仿真(带真实内存映射的 Renode/QEMU,或开发板)。

未模拟:GPU 渲染。AP510 有一个 250 MHz GPU 负责光栅化——填充、混合、字形 blit 与缩放(放大)。我们的沙箱用纯软件渲染 LVGL(无 GPU),所以 GPU 本该做的像素推送被记到了 CPU fuel 上。我们刻意不建模 GPU——一个忠实的 GPU 时序模型需要我们没有的硅片校准。后果:渲染时间是软件上界,不是设备延迟,且对绘制密集的操作(整屏合成、动画放大)高估最多。它影响:RAM 预算(硬上限)、CPU 侧逻辑/协议开销、BLE 链路模型、相对比较——这些都成立。也意味着这里的渲染优化(脏矩形、增量合成)在端上买到的延迟比模拟器显示的少,但它们仍减少 GPU 工作、PSRAM 流量与功耗。

模拟器的建议

  • 内存——宽裕。帧缓冲在 PSRAM 后,内部 SRAM 只装 UI 工作集——~640 KB / 3 MB。LVGL 池峰值 11 KB → 建议 LV_MEM_SIZE ≈ 32 KB。帧缓冲用掉 64 MB PSRAM 中的 ~1.8 MB(4-bit 下 ~0.9 MB)。
  • CPU——本地渲染现已便宜。有了脏矩形渲染,一次本地焦点移动增量渲染:端上 ~986 k fuel ≈ 4 ms,从整屏重绘的 ~28 ms / ~7 M 降下(~)。换屏(应用态)仍做一次 ~28 ms 的整屏渲染。
  • 链路——视图态零开销。聚焦 / 滚动:0 字节,全本地。打开 / 返回:~40–45 B↑ / 0.5–0.9 KB↓,体感 ~80–110 ms。
  • 手机推送的更新便宜——两层都是。一次实时同屏变化(电量 %)作为 Patch 发出(~100 B vs ~600 B 整视图),靠重写一行文本应用(~1.5 ms / ~370 k fuel,对比 ~28 ms / ~7 M 整树重建)。
  • 本地化率。可按应用测量;下一步是用一组真实应用组合得到代表性比值。
脏矩形渲染——已实现

本地移动现在保留 LVGL 对象树,只改变化的行 + 高亮,于是 LVGL 只重绘那些区域(DIRECT 模式保持两块缓冲一致)。实测:一次焦点移动从 ~28 ms(整屏重绘)降到 ~4 ms——约 7×,舒适地落在 100 ms「即时」预算内。换屏仍做一次整屏渲染;下一步渲染优化将瞄准它们。

待确认问题

需要各团队明确的问题

给固件 / 硬件团队

  1. 确认 3 MB 内部 SRAM,以及要给 BLE 栈 + 应用 + OS 预留多少(剩多少给 UI)。
  2. 帧缓冲放置与格式:两块 720p 帧都放 SRAM,还是卸到 64 MB PSRAM?L8 渲染 + 4-bit 发送,还是全程 4-bit?(帧缓冲 RAM 在 0.9–1.8 MB 间摆动。)
  3. 完整的内存 / 显存管理设计:目前只做了一部分(两块 720p 帧缓冲放 PSRAM、SRAM 留 ~640 KB 工作集)。还需成体系的设计——SRAM / PSRAM 的分配策略、帧缓冲放置与格式(见上)、PSRAM 带宽 / 延迟对渲染的影响、字体与资产从 NAND 的分页、LVGL 池与栈预算。
  4. Actions ATW6095 + 协议栈 实际能达到的 BLE 吞吐 / 连接间隔
  5. 渲染路径现实的预算。

给应用软件团队

  1. 软件耗电预算:单个应用能用多少 CPU / 唤醒频率 / BLE 流量?这决定 tick 频率、刷新率与后台行为——需要一个明确的功耗上限(射频才是真正的功耗瓶颈,不是渲染)。
  2. 后台运行限额:所有应用默认后台常驻运行——是否需要按应用类别限制后台 tick 频率 / 算力,避免 12 个应用都重度后台耗电?(见多桌面