系统、应用运行时、开发者体验——设计、取舍与模拟验证。
本文把眼镜 OS 拆成三个设计支柱来呈现。除开发者 SDK 仍是设计稿外,其余部分均已可运行。一套贴合 AP510 芯片的模拟器用于验证设计:内存能否放进 3 MB、交互是否够快。
眼镜端的系统:多应用眼动追踪桌面、本地 vs 上报 的交互模型、眼镜端手势识别、上下文菜单、可选全屏、增量渲染,以及构建 UI 的组件化 DSL。
手机是应用的 App Host——放在手机而非云端:持有每个应用的状态 / 数据、把它装进沙箱跑、管它的生命周期(安装 · 启动 · 挂起 · 恢复 · 关闭);含 App Manager 界面与驱动菜单、语音、导航的上行通道。
如何构建并分发一个应用:组件库作为构建面、这套双进程模拟器作为实时开发回路、以及把应用打包成可分发沙箱 WASM 的 SDK 设计。 SDK 仅设计
两个进程经建模的 BLE 链路通信;眼镜「大脑」以 wasm 运行在硬性 3 MB 上限 + CPU fuel 计量下,由真实端上图形库绘制——每次交互都换算成数字(延迟瀑布、fuel、相对上限的内存占用)。
本节假设读者已了解 v0 方案:有状态的云端 App Runtime 持有应用态并下发语义 diff;手机 Bridge(带缓存 + 预测)转发;眼镜渲染受限 DSL 并执行一小组预授权本地转移,让高频交互不上行。下面只列:保留了什么、改了什么、以及为什么。
| 方面 | v0 方案 | 本设计 | 结论 |
|---|---|---|---|
| 视图态 / 应用态划分 | 核心思想 | 已实现;确认可作为整个回路的基础 | 保留并验证 |
onEdge clamp/wrap/bubble | 本地↔应用 的边界 | 已实现 | 保留并验证 |
| 眼镜渲染器 = LVGL | 端上用 LVGL | LVGL 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 之外的新增」。
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。 |
v0:云端在每个 ViewBundle 里下发显式 localTransitions 表,声明眼镜可本地处理哪些事件、以及如何处理(patch)。本设计:每个类型化组件自带预授权的本地行为——List 自带 focus / scroll,Text 自带 scroll——再加一条 on_edge 规则;不单独下发表。
理由:组件类型本身就蕴含其本地行为,把它编码进类型 → 线上更小(无需下发表)、不会与树脱节、眼镜端更简单(无需查表,渲染器即知)。v0 的原则被完整保留——眼镜无需业务逻辑即可处理本地交互——只是机制不同(用静态类型代替运行时表)。代价是逐实例的灵活性(App Host 无法单独为某个 list 重新授权转移),目前没有应用需要它。
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 没有内存管理设计——它对显示是显示无关的,只标注了 ~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 池与栈预算,都待补。列入待确认问题。
onEdge 边界、LVGL、共享 Rust core——保留。App Host 位置与 transition authorization 机制由本设计改动;DSL 词汇表沿用 v0、已实现大部分;蓝牙传输沿用 v0(部分验证);显示与内存模型据真实芯片具体化;二进制 wire protocol 与预取留待后续;cloud App Host 按需另置;AI 内容生成则由 DSL 天然支持(schema 可校验的 JSON,AI 可动态生成 / 下发),交由应用开发者按需启用。
本节是这套眼镜 OS 的完整设计:它如何分层、靠什么承重原理(view-state / app-state)运转、桌面与输入怎么组织、UI 用什么组件词汇(DSL)、以及渲染怎么做(全屏、增量更新)。
S300 OS 是 server-driven-UI / 瘦客户端范式,App Host 在手机。整套系统是两层 + 中间一条 BLE 链路:
应用跑在这里、持有 app 态(见应用运行时)。把一棵受限组件树(ViewBundle)推给眼镜,同屏变化推 Patch;接收眼镜上报的事件、跑应用逻辑、回新视图。
用 LVGL 把 DSL 渲染到两块 720p 帧缓冲;能本地闭环的交互(焦点 / 滚动)就地解析、就地重绘、不上链路;碰到 app 态就上报手机。
中间是 BLE——L2CAP CoC 走 diff 流、GATT 走控制 / 回告。手机承载应用逻辑与桌面合成两层,眼镜只负责渲染。
这是整套 OS 的承重设计:把交互分成两类,尽量多地在眼镜本地闭环。
判定一个交互能否本地化,用三条准则:闭包性(所需数据已在设备上)、确定性(下一帧 = f(本地态, 事件))、可重放性(手机能用同一个 f 算出同样结果)。三条都满足才本地,否则上报手机。
边界旋钮是 onEdge:本地转移撞到数据边界时——clamp(停住)/ wrap(绕回)/ bubble(降级成 app-state 上报)。「撞边 → bubble」就是 view-state 与 app-state 的边界:固件实现了它,本地处理不了的情况就自动上报。
每个输入先映射成语义动作(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-start、long-press、press-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。
confirm 上报手机,应用算出下一个视图,手机压栈、把它作为一次 push 下发,眼镜也压进自己的缓存栈。double-tap(未被改绑时)眼镜弹自己的缓存栈、立刻重渲染上一视图——不走往返(与切桌面同理);同时上报一个 {back}(fire-and-forget)。手机据此弹自己的栈、跑 on_pop;只有当上一视图的数据在期间变过,才回一条 Patch 把它刷新,否则什么都不发。于是返回即时(本地渲染 + 可选的小 patch),不再是「往返 + 重发整棵上一视图」。应用不必实现返回。手机始终是源头:万一两边栈不一致,手机可直接下发完整视图重新对齐。
double-tap 是应用级事件,不是系统专属。应用可以在视图的动作映射里把它绑到自己的目标。但只要应用没有接管它,系统就默认捕获它执行返回(pop)——所以「后退」对应用是免费的(不写一行代码即得),需要时又能改绑成别的行为。这正是把返回做成系统原语的方式。
应用内没有眼动追踪。眼动只在桌面层决定哪个应用被聚焦,进了应用就没有指针——一个手势在视图里没有空间目标,系统无从知道你「点的是哪个组件」。由此得到一条硬约束:同一个视图里,一个动作只能由一个组件响应。动作路由就是把每个动作确定地落到那唯一一个处理者。
每个视图带一张「动作 → 目标」映射,每个动作至多一个目标。它是一张 map——「两个组件订阅同一个动作」从数据结构上就无法表达,而不是靠校验事后拦截。目标是三种之一:
key——把动作交给该组件(如 confirm → 列表、press-start/press-end → 某个录音组件);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 是一棵类型化组件树,序列化成 JSON 下发到眼镜(serde 编码;将来可换二进制省字节),眼镜照它渲染、就地处理本地交互。它本质是一份 JSON 版的精简 HTML:节点 = 「元素」,布局 = 「盒模型 + flexbox-lite 的 CSS」,Screen 上的动作映射 = 「事件绑定」,整体收窄到一块单色 HUD 能承受的范围。共 15 个组件。
顶层是 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)
}
}
}
}
}
嵌套规则(内容模型 · 谁能装谁)
| 节点 | 子节点 |
|---|---|
Screen | body:恰好一个子节点(要放多个就装进 VStack/HStack);外加一张 actions 映射 |
VStack / HStack | children:任意多个节点 |
Box | child:恰好一个子节点 |
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;把动作路由给可聚焦子节点)
| 组件 | 字段(类型 · 默认) | 固有高度 | 渲染 / 语义 |
|---|---|---|---|
Screen | key · title: String · actions: Map<Action,Target> = ∅ · body: Node | 标题栏 ≈84px + body 填满其余 | 根 = 一个视图:顶部一行标题 + 一个可交互 body;actions 见动作路由 |
VStack | key · children: [Node] · justify = Start | Σ子高 + 8px×(n−1) | 竖直堆叠(flex-direction:column);justify 分配富余高度 |
HStack | key · children: [Node] · justify = Start | max(子高) | 水平排列(flex-direction:row);子宽按内容估算;justify 分配富余宽度 |
Box | key · margin: Insets = 0 · child: Node | margin.top + 子高 + margin.bottom | 给单个子节点四周加 px 留白(仅 margin) |
Spacer | key | 0(主轴弹性) | flex-grow:1:吃掉容器主轴富余空间;多个均分 |
可交互(持 view-state;本地解析焦点 / 滚动)
| 组件 | 字段(类型 · 默认) | 固有高度 | 渲染 / 语义 |
|---|---|---|---|
List | key · items: [String] · focus: usize · top: usize · rows: usize · on_edge: Edge | 可见行数×36 | 窗口化列表。scroll 移 focus + 滑 top(本地);confirm 上报(带 focus);撞数据边界按 on_edge。view-state:focus·top |
Text | key · lines: [String] · top: usize · rows: usize | 可见行数×36 | 滚动文本。scroll 滚一行(撞底 clamp,本地)。view-state:top |
RunningText | key · lines: [String] · max: usize | 行数×36 | 追加式文本流:AppendText 增长、超 max 顶部滚走;无本地滚动。Conversate 用 |
VoiceInput | key · label: String · value: String · recording: bool | 78(2 行 + 6) | 语音输入框。press-start/press-end 上报 → 手机 ASR → SetVoice 回填 value/recording。无本地转移 |
纯展示(无交互、无 view-state)
| 组件 | 字段(类型 · 取值) | 固有高度 | 渲染 / 语义 |
|---|---|---|---|
Heading | key · text: String | 44 | 强调的分区标题 |
Divider | key | 18 | 水平分隔线 |
KeyValue | key · label: String · value: String | 36 | 左标签 / 右取值一行(value 右对齐) |
Progress | key · label: String · value: u8 (0–100) | (有 label ? 30 : 0) + 18 | 线性进度条 |
Toggle | key · label: String · on: bool | 36 | 开 / 关读数 |
Badge | key · text: String | 32 | 行内小 chip |
Icon | key · glyph: String | 28 | 由矩形拼的图元(不用符号字体)。已实现 glyph:dot / ring / square / box / bar;其余名 → 占位方框 |
Image | key · w: u16 · h: u16 · dots: [[i16;2]] | ih×min(w,560)/iw | 矢量折线图(点采样);唯一可做大的载荷,用来探 BLE 在途开销 |
设计内、尚未补的组件 未实现
| 组件 | 设想字段 | 用途 |
|---|---|---|
Button | key · label · 应用动作 id | confirm 直接触发一个应用动作,免去用 List 间接选 |
Disclosure | key · label · open: bool · children | 可展开 / 收起分组——切 open 是真正的本地 view-state |
Gauge | key · label · value | 弧形 / 圆形仪表(区别于线性 Progress) |
Tabs | key · tabs: [String] · active | 标签页,本地切活动页(view-state) |
Image(光栅) | key · w · h · 像素 / 编码 | 真正的位图(现仅矢量折线) |
DSL 里没有绝对坐标:一个组件放在哪、占多大,完全由它所在的容器和顺序决定。三个容器 + 三个布局控制就是全部规则——一套 flexbox-lite + 盒模型。
坐标与几何
整块显示 1280×720 px。一个窗口视图渲染进一个 tile(三列之一,内容宽 ≈ tile_w − 2·CPAD,CPAD = 14);聚焦的 tile 用全宽、未聚焦的按比例缩小(内容随之重排)。单位都是 px:基准字体 ~28px、按字数估宽 ≈ 15px/字,行高基准 ROW_H = 36。全屏模式下视图改为铺满整屏(见全屏模式)。
两遍布局算法(渲染器自己算 x/y,不依赖 LVGL flex)
VStack 求子高之和、每两个之间加 8px;HStack 取最高的子节点、子宽用按字数的估算;Box = margin + 子高。VStack 从上往下、HStack 从左往右;Spacer 吸收主轴余量(多个均分),没有 Spacer 时由 justify 分配余量;Box 把 child 按 margin 偏移。叶子节点直接画到算出的 (x, y)。容器:决定排布方向
| 容器 | 排布 | 子节点字段 · 间距 |
|---|---|---|
Screen | 标题(顶部一条)+ 正好一个子节点 | body(单个) |
VStack | 子节点竖直堆叠(从上往下,每个占满整行宽)——即 CSS flex-direction:column | children[] · 8px |
HStack | 子节点水平排列(从左往右,宽度按内容估算)——即 flex-direction:row | children[] · 12px |
VStack = 竖直堆叠(V = vertical,孩子上下排)、HStack = 水平堆叠(H = horizontal,孩子左右排)。Screen 只放一个子节点,要在一屏放多个组件就装进 VStack 或 HStack;默认「每个组件占一行、上下排」就是把 body 设成一个 VStack。
布局控制:对齐与留白
| 用什么 | 作用 | 对应 CSS |
|---|---|---|
Spacer | 吃掉容器的剩余主轴空间:在 VStack 里把后面的节点推到底部,在 HStack 里推到最右;多个 Spacer 均分剩余空间 | flex-grow:1 |
VStack/HStack 的 justify | 主轴整体对齐:Start(默认)/ Center / End / Between。仅当容器有富余空间(如根容器铺满 tile)时生效;若含 Spacer 则让位给它 | justify-content |
Box{ margin, child } | 盒模型包裹:给单个子节点四周加 px 留白(top/right/bottom/left)。它的字段是 child(单个节点)——区别于 VStack/HStack 的 children[](数组);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。「靠右 / 靠底 / 居中 / 均分」用它们已经够;以下都还没有,按真实应用需求再补:
VStack 子节点现一律占满行宽、HStack 现一律顶对齐,没有「列内左 / 中 / 右」「行内顶 / 中 / 底」。Box 的外边距 margin,没有内边距。HStack 子宽也只按字数估算,CJK 偏窄。List / Text 自带窗口;比 tile 高的 VStack 会被裁掉,做不了可滚动的长表单。交互模型已在前面三节讲全:交互动作(动作词汇、系统 vs 应用)、动作路由(每视图一张映射 + 默认)、视图与导航(视图栈、push / pop)。这里只补一张组件级的表:一个动作落到某类可聚焦组件时,apply() 判它本地解决还是上报。
解释器 shared::apply(root, action) 先用 first_interactive 从树根下钻到第一个可聚焦节点(穿过 Screen.body 与 VStack/HStack 的子节点),再按其类型裁决这个动作能否就地解决:返回 Local(就地改树、就地重绘、不上链路)或 Bubble(AppEvent)(上报手机定夺)。下表是路由给组件的动作;double-tap(系统返回)与 click+hold(上下文菜单)是系统动作、不在此表。
| 可聚焦节点 | scroll | confirm | 撞数据边界 |
|---|---|---|---|
List | 移动焦点 + 滑动窗口 → 本地 | 上报(带 focus) | 按 on_edge:clamp / wrap → 本地;bubble → 上报 |
Text | 滚动一行(clamp)→ 本地 | 上报(focus=top) | clamp,仍本地 |
RunningText | 不滚动 → 本地 no-op | 上报 | — |
| 仅展示 / 无可聚焦子节点 | 全为本地 no-op(动作路由给可聚焦子节点,没有就忽略) | — | |
要点:List 把 focus(选中项)与 top(首个可见行)分开——焦点移出可见窗口时只滑动 top,全在端上;只有焦点移出数据本身才触发 on_edge。confirm 上报时随事件带上 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 的那部分;那是模拟器的细节。)
解释器持有的状态
List.focus/top、Text.top)就存在树节点里,本地交互就地改。两个输入源
① 下行消息(手机 → 眼镜):ViewBundle 替换 / push / pop 一个槽位的树;Patch 把 Op 套到缓存树上、保留 view-state;clear 清空槽位。后台槽位(不在前台)照常入树、但不渲染,直到被切到前台(与多桌面同理)。
② 输入(眼动 + 手势):眼动追踪只在桌面层决定哪个 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_event → Chat::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/glasses 的 handle_gesture;handle_event / on_event 在 crates/phone。
受限、封闭的词汇让出错面很小;解释器对异常一律安全降级、绝不崩:
Op 指向不存在 / 重复的 key:该 Op 落空——不报错、不动其它节点。focus / top / index 超范围一律夹回有效区间(增删列表项后即按此夹紧);Progress.value 超 100 截断。ViewBundle 把该槽位重新对齐。DSL 是个有意保持小的封闭集合:眼镜对每个节点类型、每个 Op 的处理都预先实现好,没有运行时解释。加能力 = 加一个类型 / 一个 Op(改 shared 一处,手机眼镜共享)。当前还没覆盖、标 未实现 的:
Op:现有 5 个只覆盖 List / RunningText / VoiceInput。SetText / SetProgress / SetToggle / SetKeyValue 等还没有——所以多数展示组件目前要整树重发才能更新;结构性增删节点(而非改数据)也只能整树重发。DSL 是一套受限、封闭、可 schema 校验的组件集——眼镜端一个递归渲染器把任意组件树变成 LVGL 对象树。下面是每个组件在绿色 HUD 上的渲染样子、规格与用途。(手机目录里有个「Components」应用可现场展示。)
规格:根框——一个 title + 一个 body。本地:无。用途:每个应用的外框 / 平铺块。
规格:把 children 自上而下堆叠。本地:把手势路由给可聚焦的子节点。用途:页面布局、分区。
规格:把 children 自左而右排列。本地:把手势路由给可聚焦的子节点。用途:行内分组(badge、icon、kv)。
规格:一行强调的 text。本地:无。用途:分区标签。
规格:lines + 一个滚动窗口。本地:滚动(上 / 下)。用途:详情正文、段落。
规格:带窗口的 items + 焦点。本地:焦点移动 / 滚动;confirm 上报。用途:菜单、通知、曲目列表。
规格:一条水平分隔线。本地:无。用途:分隔区块。
规格:label 左、value 右。本地:无。用途:状态对、设置读数。
规格:可选 label + value 0–100。本地:无。用途:存储、下载、播放进度。
规格:label + on 布尔。本地:切换(计划中)。用途:设置开关。
规格:一小块 text 标签。本地:无。用途:计数、状态标记。
规格:一个命名 glyph(dot · ring · square · box · bar),由矩形拼出——不用符号字体。用途:行内状态图元。
规格:矢量图,以 w·h·dots[] 表示,点描以适配宽度。本地:无。用途:图示、地图、插画——也是刻意的 BLE 开销探针:点越多 = bundle 越大 = 在途延迟越明显。
规格:带窗口的 lines[] 流,带 max 尾长。本地:无;经 AppendText op 增长。用途:实时转写、聊天记录、日志。
规格: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 流量 → 更少射频、功耗、延迟。增量更新怎么写 —— Patch 与 Op
一个 Patch = 版本号 + 一列 Op。每个 Op 用目标节点的 key 定位、只改它的数据(不动树结构);眼镜对缓存的那棵树原地套用(apply_patch),再只重绘受影响的 tile。现有五个 Op,按真实应用需求生长:
Op | 目标组件 | 字段 | 作用 |
|---|---|---|---|
SetListItem | List | key · index · text | 改某一行的文本(如电量行刷新) |
InsertListItem | List | key · index · text | 在 index 处插入一行(index = 长度即追加),其后的项下移;眼镜顺带把焦点 / 可见窗口跟着挪 |
RemoveListItem | List | key · index | 删除 index 处的一行,其后的项上移;焦点 / 窗口随之收紧 |
AppendText | RunningText | key · text | 在文本流末尾追加一行;超过 max 时顶部最旧的滚走(实时转写 / 日志) |
SetVoice | VoiceInput | key · 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)。换屏仍发整视图,这是对的:那是新屏,不是增量。
这一节是给手机应用平台团队的架构设计。应用放在手机(而非云端),手机就是应用的 App Host——它承担三件事:
wasmtime 实例——沙箱隔离、内存硬上限、fuel 计量。tick(隐藏页只是不渲染、不接收交互)。下面是完整方案——当前以树内注册的 Rust 模块跑通,目标是把每个应用做成沙箱 WASM 容器。
应用编译成一个 WebAssembly 模块。手机运行时把每个已安装应用加载进它自己的 wasmtime 实例——独立线性内存、逐回调的 fuel 预算、除我们交给它的 import 外没有任何 capability。这正是眼镜大脑已经在用的方式:复用一个验证过的沙箱,而非另造一个。
应用跑在手机,绝不跑在眼镜上。眼镜受内存与 CPU 约束(3 MB SRAM),只渲染 DSL 并解析本地视图态。手机有余量托管许多沙箱模块。这条划分已经成立。
wasmtime 提供内存隔离、逐次调用的 fuel 上限(失控应用卡不死手机)、以及不 import 就没有任何 syscall。
任何能编到 wasm32 的——Rust、C/C++、TinyGo、AssemblyScript、Zig。ABI 与语言无关。
DSL(shared)与端上 LVGL 渲染器不变。应用只产出 Node/Op——它画不了任意像素。
契约对应今天的 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() | 共享缓冲 |
host_log(ptr,len) | 始终 |
host_now() → u64 | 始终 |
host_storage_get/set | storage |
host_asr_request() | voice_asr |
host_http(req) | network |
host_timer(ms) | timers |
字节经共享缓冲往返(把参数写到 app_inbox_ptr()、调导出、读返回的指针 / 长度)——边界两侧不解引用指针,模块保持完全沙箱化。这里用 Node/Op 的二进制编码代替 JSON 以减小体积,下游的 BLE 预算也随之收紧。
生命周期:installed → running ⇄ suspended → closed。启动把模块实例化进某槽位的 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 的模块——前后向都安全。
这些应用交互共用一套上行机制——眼镜 → 手机的上行路径,与既有的下行视图并存:
| 眼镜上的触发 | 上行消息 | 手机处理 |
|---|---|---|
| 上下文菜单选应用动作项(click+hold 打开) | {action:{slot,id}} | app.on_action(id) → Op[](patch) |
| 应用启动器选项(空槽位 click+hold 打开) | {launch:{slot,id}} | 运行时把应用启动进该槽位 |
按住(hold)录音 → press-start / press-end | {event:{slot,action,…}} | 路由到 VoiceInput → app.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),应用不必处理——于是应用在同一协议上于视图间导航(联系人 → 会话,图片 → 下一张)。
模拟器跑在 Mac 上、用来验证设计(内存、延迟、交互),并非真实手机运行时本身——也不是开发手机应用的地方。共用、两边一致的那份——shared DSL、App trait、LVGL 渲染、view-state / app-state 划分——不在此列;下面只标当前模拟环境与真实手机实现的区别。
| 方面 | 当前模拟器(Mac) | 真实手机 |
|---|---|---|
| 应用形态 | 树内注册的 Rust 模块、直接调用(编译进进程、全信任) | 经 wasmtime 加载的沙箱 WASM 包、capability 门控(容器化已设计,模拟器尚未跑 wasm 容器) |
| 宿主 | Mac 上的一个 host 进程 | iOS / Android app(原生 BLE、后台保活、配对) |
| 语音 / ASR | Mac 端 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 Image | Image 组件 + 刻意的延迟探针:点击循环 小 / 中 / 大(825 B → 7.5 KB;在途 37 ms → 152 ms) |
| Voice Note | 最简的 VoiceInput 听写流程 |
| Components | 整套 DSL 词汇表的实时画廊 |
| Notifications · Agenda · Weather · Music · Timer | 日常界面——列表、键值、进度、实时电量、逐应用菜单动作 |
开发者体验有三部分。组件 DSL是构建面——一套封闭、可 schema 校验的词汇表,应用用它拼出 UI。这套双进程模拟器是实时开发回路——跑一个应用,在查看器里看它的延迟、fuel 与内存。缺的一块是打包与分发:今天示例应用编译进 OS;为让别人能发布应用,我们设计了一个平台。下面是这个平台的完整设计——设计稿,尚未实现。
要成为一个真正的平台——让第三方无需我们的源码树即可构建应用、以自包含产物分发、用户像装 APK 一样安装(选取、授权、运行)——需要下面四样东西。均未开发
可分发、带版本、已签名的产物——代码 + manifest + 资源——无需重建 OS 即可安装。
安全加载不受信第三方代码的沙箱,带硬性内存 / CPU 上限,且无任何环境 capability。
稳定的 API 面 + 一个宏,把开发者的 handler 变成可加载模块,隐藏线上 ABI。
一个 CLI 负责脚手架、构建、校验、模拟、打包——以这套双进程模拟器作为开发回路。
应用的接口就是一个 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 还不够成为平台:它要我们的源码才能编译、以宿主全信任运行、没有包与版本、目录在编译期写死——这正是上面四样东西要解决的。
分开两个问题。包的载荷永远是 WebAssembly——那是安全与可移植的边界。SDK 是其上的开发者层,今天是 Rust 优先,因为代码库、DSL 类型与最好的 wasm 工具链都是 Rust。其他语言的 SDK 日后可叠在同一套 ABI 上。
| 选项 | 沙箱 | 开发体验 | 结论 |
|---|---|---|---|
| WASM 模块,Rust 优先 SDK | wasmtime——强,且已在用 | 写同一个 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
Close 与 Full Screen 开关,应用贡献其余项,选中后调 app_on_action(id));全屏同样靠 [fullscreen] supported = true 让桌面层提供开关,选中时宿主调 app_set_fullscreen(true),应用返回为整块 1280×720 设计的视图。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 类型、顺手的构建器(Screen、List、RunningText、VoiceInput…)以及 capability import 的薄封装(host::storage、host::asr)。C / AssemblyScript 的 SDK 会在同一套 ABI 上暴露相同形状。
glsdk CLI——模拟器即开发回路开发体验最大的一块已经就绪:这套双进程模拟器(手机运行时 + 眼镜查看器 + 实时 telemetry)就是 glsdk sim。开发者无需硬件即可在真实 LVGL 渲染上看到自己的应用、驱动手势,并观察每次点击的延迟、BLE 字节、内存与 fuel。
| 命令 | 做什么 |
|---|---|
glsdk new myapp | 脚手架一个 Rust crate:App 桩、一个 manifest.toml、一个示例视图。 |
glsdk build | cargo 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。
视图态交互(聚焦、滚动)从不过链路——它们在沙箱里解决并本地重绘。只有应用态交互(打开、返回、提交、语音)才上报手机。
模拟器左半边是真实尺寸的 1280×720 绿色 HUD(单绿 #3DFA44、4-bit),下面是代替镜腿触控 / IMU 的手势按钮;鼠标移到屏上 = 眼动追踪。下图是多应用桌面(三个平铺块,中间为被聚焦、放大聚焦的应用)。
真实运行界面的 HTML 复刻(非截图)。
「对照硬件」靠三个机制,把三种最关键的硬件约束变成可强制、可量化的指标:
眼镜大脑编译成 wasm32 跑在 wasmtime 里,用 StoreLimits 把线性内存钉死在 3 MB(= AP510 内部 SRAM);涨过即 trap(真实 OOM)。所以「放不放得下」是被硬性检验的,不是纸面估算。
wasmtime 的 fuel 精确计数一次渲染执行了多少条 wasm 指令(确定性的「工作量」);再除以假定的设备速度(M55 @ ~250 MHz、~1 指令/周期)得到端上估算毫秒。它不计宿主墙钟时间,在任何机器上结果一致。
crates/link 是一个限流、计量的 BLE 替身:按连接间隔、带宽、单程延迟、MTU,给每次传输算出包数与在途时间。于是 view-state(本地)与 app-state(往返)的链路代价都是被建模、可量化的。
同样要讲清它做不到什么——三条最重要的:
fuel→ms 的除数是假定的(M55 主频、~1 指令/周期),没用真实 AP510 校准。所以绝对 ms 约 ±2–3×;可信的是相对比值(优化前后、A vs B),不是毫秒承诺。
AP510 有一个 250 MHz GPU 做光栅化(填充 / 混合 / 缩放)。我们的沙箱用纯软件渲染 LVGL,GPU 本该做的活被记到了 CPU fuel 上 → 渲染时间是软件上界,对绘制密集操作(整屏合成、动画放大)高估最多。
真机里帧缓冲在 PSRAM、工作集在 SRAM,两者带宽 / 延迟不同。我们在一块扁平的 wasm 线性内存里建模,无法真实区分 SRAM↔PSRAM 的访问差异(PSRAM 较慢会加一些渲染 / 扫出延迟,需在硬件上测)。
| crate | 目标 | 职责 |
|---|---|---|
shared | no_std | 协议(DSL Node、ViewBundle、AppEvent)+ 视图态状态机 apply()——那个「one f」,注定要上设备。 |
glasses-lvgl | host + wasm | 把 DSL 映射到真实 LVGL v9(Even 的 lvgl-sys-v9 分支)。渲染到帧缓冲。 |
glasses-wasm | wasm32-wasip1 | 眼镜大脑作为沙箱模块(LVGL + 状态机),带一个宿主驱动的小 C-ABI。 |
glasses | host | 在 wasmtime 里跑 wasm 模块(3 MB 上限 + fuel)、链路客户端、查看器与全部 telemetry。 |
phone | host | App Host + 一个示例「notifications」应用。 |
link | host | 限流、计量的 BLE 替身。 |
| 能力 | 状态 |
|---|---|
| 由真实端上图形库(LVGL v9)绘制的交互 UI | 可用 |
| 多应用桌面 + 眼动追踪指针(桌面合成器) | 可用 |
| 本地处理 vs 往返手机,在眼镜上判定 | 可用 |
| 跑在芯片 3 MB 内存预算内,CPU 计量 | 可用 |
| 实时 telemetry / 开发者视图 | 可用 |
| 本地移动的增量(脏矩形)渲染 | 可用 |
| 应用运行时与生命周期 + 手机安装 / 启动界面 | 可用 |
| 完整手势集 · 上下文菜单(含 dismiss)· 语音听写 · 逐应用导航 | 可用 |
| 可选全屏模式,应用自供视图 | 可用 |
| 从眼镜启动应用(空槽启动器) | 可用 |
| 应用平台 / SDK 设计(应用即沙箱 WASM 包) | 已设计 — 见 开发者体验 |
| 在真实 Cortex-M55 硬件上验证(精确计时) | 暂未 |
| 真实 ASR 模型(当前为手机 / Mac 端替身;ASR 一直在 App Host 侧,不在眼镜端) | 暂未 |
| 二进制 wire protocol(替换当前 JSON) | 暂未 |
| 功能 | 器件 | 对模拟器要紧的点 |
|---|---|---|
| MCU | Ambiq AP510(Apollo510) | ARM Cortex-M55 + Helium MVE,~250 MHz;3 MB 内部 SRAM |
| PSRAM | AP Memory APS512 | 64 MB 外部——也可放显示缓冲 / 大资源 |
| NAND | GigaDevice 512 Mb | 64 MB——代码、字体(含 CJK)、资源(非 RAM) |
| BLE | Actions ATW6095 | 手机↔眼镜链路是经独立控制器的 BLE |
| 显示 | 1280×720 面板 | 单绿 #3DFA44,4-bit 压缩(16 级) |
| 输入 | Azoteq 触控 · TDK IMU · Syntiant VAD | 触控 + 头部手势 → 语义事件;语音 → 应用态 |
显示需要两整块 720p 帧缓冲(一块绘入、一块扫出——双缓冲)。约 0.9–1.8 MB,会吃掉 3 MB 内部 SRAM 的大头,所以模拟器把它们放进 64 MB 外部 PSRAM(我们的假设,待固件确认)。内部 SRAM 于是只装 UI 工作集——组件树 + LVGL 的池(几十 KB)。权衡:PSRAM 访问比 SRAM 慢,会增加一些渲染 / 扫出延迟,值得在硬件上测。
| 位置 | 装什么 | 大小 |
|---|---|---|
| PSRAM · 64 MB | 2× 720p 帧缓冲(双缓冲) | L8 下 ~1.8 MB · 4-bit 下 ~0.9 MB |
| internal SRAM · 3 MB | UI 工作集:组件树 + LVGL 池 + 栈 | 共 ~640 KB(LVGL 池峰值 11 KB → 建议 ~32 KB) |
| NAND · 64 MB | 代码、字体(含 CJK)、资源 | 按页换入;非 RAM |
模拟器据以下参数对照硬件测量;完整逐参数理由见 docs/ASSUMPTIONS.md(参数的权威出处,与代码同步)。
| 参数 | 取值 | 标记 |
|---|---|---|
| 内部 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:连接间隔 / 带宽 / 延迟 / MTU | 30 ms / 60 KB/s / 8 ms / 244 B | 假设 |
| wire encoding | JSON,完整 ViewBundle(暂无 diff / 二进制) | 假设 |
| wasm shadow stack | 128 KB(默认 1 MB 会浪费预算) | 待确认 |
LVGL LV_MEM_SIZE | 设 256 KB → 峰值 11 KB → 建议 ~32 KB | 实测 |
查看器相当于眼镜的浏览器 DevTools——每次交互都换算成数字。本节也讲它已经测到了什么。
布局:左边是实时 1280×720 显示,右边是一摞面板——每个信号一块。逐一说明怎么读。
下图是右侧面板的 HTML 复刻(非截图):事件日志(每行一次交互 + 四段延迟瀑布)、CPU(fuel → 估时)、内存(对 3 MB)、BLE 链路。
真实 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。
LV_MEM_SIZE)。⌈(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,或开发板)。
LV_MEM_SIZE ≈ 32 KB。帧缓冲用掉 64 MB PSRAM 中的 ~1.8 MB(4-bit 下 ~0.9 MB)。本地移动现在保留 LVGL 对象树,只改变化的行 + 高亮,于是 LVGL 只重绘那些区域(DIRECT 模式保持两块缓冲一致)。实测:一次焦点移动从 ~28 ms(整屏重绘)降到 ~4 ms——约 7×,舒适地落在 100 ms「即时」预算内。换屏仍做一次整屏渲染;下一步渲染优化将瞄准它们。
tick 频率、刷新率与后台行为——需要一个明确的功耗上限(射频才是真正的功耗瓶颈,不是渲染)。tick 频率 / 算力,避免 12 个应用都重度后台耗电?(见多桌面)