机器人也有应用商店?从 App 模板看机器人软件生态
- 看懂官方 App 模板仓库的构成:打包约定、示例工程、构建产物形态
- 想清楚机器人 App 与手机 App 的三个结构性差异:能力边界、资源仲裁、异常隔离
- 学会把模板本身当参考实现用——即使你不打算做应用分发
有一天你在一台机器人的官方仓库列表里翻到一个叫 unitree-app-templates 的项目,描述写着 "App Templates for Unitree App Store"。
停一下。应用商店?
这四个字在手机上已经平常到没人多看一眼,但放在一台会走路的机器上,它意味着一件挺不一样的事:机器人正在从"一台你买回来用的设备",往"一个别人可以往上装东西的平台"挪。你写的代码,理论上可以跑在别人家的机器人上。
这篇我们就把这个模板仓库拆开看。它其实很小——整个仓库两百多个路径,一多半还是第三方库的头文件,真正属于宇树自己的东西不到一百个文件。但小仓库有小仓库的好处:约定都摆在明面上,没地方藏。
先把边界说清楚:本文所有结论都来自这个公开仓库的 README 和文件树,以及它依赖的 unitree_sdk2 的结构。我没有真机,没有跑过任何一行,也没有上架过任何应用。凡是涉及"跑起来是什么效果"的地方,我只转述仓库文档的说法。至于上架流程、审核规则这类仓库里根本没写的东西,我一句都不会编。
仓库给了什么:一份约定,加一个例子
README 的自我定位是"开箱即用的开发环境",仓库按 monorepo 组织。翻到底,它实质上只给了两样东西。
第一样是打包约定。 README 里有一张很短的表,说明一个应用要能发布,最少需要什么:
| 文件/目录 | 是否必需 | 说明 |
|---|---|---|
app/ |
是 | 放运行程序所需的二进制或脚本,入口点在 metadata.yaml 里定义,且必须在 80 端口提供 HTTP 服务 |
metadata.yaml |
是 | 应用的元数据与配置 |
同一段还有一句话值得单独拎出来:应用以 Docker 作为底层基础设施部署,任何编程语言或框架都支持。
打包动作本身简单得有点朴素:
tar -czvf your_app_name.tar.gz ./app
然后把这个 tar 包上传到 UniStore。
第二样是一个示例工程,目录叫 g1_mimic_demo/。README 的表格里对它的描述是:G1 Mimic Learning Demo,C++ 写主控制器、Python 写服务,演示怎么用 mimic learning 策略让 G1 做出动作,举的例子是 Gangnam Style 和开场拳。启动方式是:
cd g1_mimic_demo
python3 ./app/g1/cmd_server.py
就这些。表格只有一行,示例只有一个。这个仓库目前的状态就是这样,我不去猜它后面会不会长出更多示例。
三条约定,每条都在回答一个具体问题
上面那张表看着平淡,但每一条其实都在解决一个平台方绕不开的问题。
为什么是 Docker? 因为"任何语言任何框架"这句承诺,只有靠容器才兑现得了。你的应用可能是个 Python 服务,可能是个静态链接的 C++ 二进制,可能带一堆奇怪的系统依赖。平台方不可能为每种技术栈维护一套运行时,最省事的做法就是:你自己把环境打包好,我只负责把这个盒子跑起来。这跟手机平台的思路正好相反——手机平台通常强约束语言和 SDK,用统一的运行时换取管控力。
为什么必须在 80 端口提供 HTTP 服务? 这一条是整份约定里信息量最大的。它等于规定了应用与外界的唯一交互面是 HTTP。不是自定义协议,不是 socket,不是共享内存,就是 HTTP。
这样一来,机器人的系统界面、手机 App、或者任何一个上层控制器,都可以用同一套方式去"打开"一个应用:往它的 80 端口发请求。应用内部爱怎么实现怎么实现,但对外必须长成一个 Web 服务的样子。统一交互面是做平台的前提——如果每个应用都用自己的方式跟系统对话,平台就永远拼不出一个统一的启动器、也做不出一致的用户体验。
为什么入口点要写在 metadata.yaml 里? 因为容器需要知道启动什么。把入口点从代码里挪进声明式配置,意味着平台不需要理解你的代码结构,只需要读一个 YAML。这是所有包管理体系的通用做法。
顺带说一句,metadata.yaml 具体有哪些字段,README 只写了"基本应用信息和配置",文件树里那个文件的内容我们看不到。所以字段清单我不写,仓库没给的东西就是没给。
拆开 g1_mimic_demo:一个示例的两副面孔
真正有意思的在示例工程内部。把 g1_mimic_demo/ 的顶层列出来:
g1_mimic_demo/
├── .dockerignore
├── Dockerfile.arm64-builder
├── docker_build_arm64.sh
├── metadata.yaml
├── README.md
├── README_zh.md
├── app/ ← 交付物
└── deploy_template/ ← 源码工程
app/ 和 deploy_template/ 是同一个东西的两个形态:一个是打包上架的成品,一个是你拿来改的源码。这个分法本身就是模板想教你的第一件事。
app/:成品长什么样
app/g1/
├── app_out/
│ ├── bin/g1_ctrl ← 编译好的主控制器
│ ├── config/
│ │ ├── config.yaml
│ │ └── policy/
│ │ ├── mimic/gangnam_style/
│ │ │ ├── exported/gangnam_style.onnx
│ │ │ └── params/
│ │ │ ├── G1_gangnam_style_V01.bvh_60hz.csv
│ │ │ └── deploy.yaml
│ │ └── velocity/v0/
│ │ ├── exported/policy.onnx
│ │ └── params/deploy.yaml
│ └── lib/ ← 自带运行时
│ ├── libddsc.so / libddscxx.so
│ └── libonnxruntime.so
├── cmd_server.py ← README 里的启动入口
├── cmd_realtime.py
├── config.py
└── run_g1_ctrl.sh
几个细节值得停一停。
第一,交付物是"二进制 + 配置 + 模型 + 运行时库"的四件套。 lib/ 里躺着 DDS 的两个动态库和 onnxruntime——应用把自己依赖的通信栈和推理引擎全带上了。这跟 Docker 那条约定是配套的:既然平台不保证给你什么环境,那就自己带全。
第二,policy/ 下面有两个策略,不是一个。 mimic/gangnam_style/ 是那个跳舞动作,velocity/v0/ 从命名看是速度控制类的策略。一个"跳舞 App"里为什么要塞一个行走策略?我不去猜作者的具体考虑,但从工程角度看,一个动作类应用光有动作是不够的——机器人得先站稳、能挪位置,才谈得上表演。这跟强化学习部署链路里的常见组织方式也对得上:不同技能各自一套策略,运行时按状态切换。
第三,params/ 里有一个 .csv 文件,文件名里带 bvh。 BVH 是动作捕捉领域常见的骨骼动画格式。文件名里出现它,说明这条 mimic 策略的参考轨迹来源跟动捕数据有关。这条线索也就到此为止了——CSV 里具体是什么列、怎么用,文件树看不到,我不编。
第四,Python 和 C++ 的分工很清楚。 cmd_server.py 是 README 指定的启动命令,run_g1_ctrl.sh 负责拉起 bin/g1_ctrl。对照打包约定里"必须提供 80 端口 HTTP 服务"这条,一个自然的读法是:Python 那层承担对外的服务面,C++ 那层承担实时控制。把"对外通信"和"实时控制"放在两个进程里,是控制类程序里很常见也很稳妥的分法——HTTP 请求是不可预测的,实时控制循环最怕被不可预测的东西打断。
deploy_template/:源码工程
deploy_template/
├── CMakeLists.txt
├── install.sh
├── main.cpp
├── run_g1_ctrl.sh
├── config/ ← 与 app_out/config 同构
├── include/
│ ├── FSM/
│ ├── isaaclab/
│ ├── unitree/
│ ├── State_Mimic.h
│ ├── LinearInterpolator.h
│ ├── Types.h
│ ├── param.h
│ ├── unitree_articulation.h
│ └── unitree_joystick_dsl.hpp
├── src/
│ ├── State_Mimic.cpp
│ └── State_RLBase.cpp
└── third_party/
├── aarch64/{mnn, onnxruntime}
└── x64/{mnn, onnxruntime}
include/FSM/ 里是一套状态机:
BaseState.h
FSMState.h
CtrlFSM.h
State_Passive.h
State_FixStand.h
State_RLBase.h
State_Exit.h
被动态、固定站立态、强化学习基类态、退出态。再加上外面的 State_Mimic.h 继承这条链。注意 State_Passive 和 State_Exit 的存在——一套状态机里专门给"什么都不做"和"退出"各留一个状态,这不是凑数。后面讲异常隔离时我们会回到这里。
third_party/ 下面是一个很整齐的二乘二矩阵:aarch64 和 x64 两种架构,各带 mnn 和 onnxruntime 两套推理引擎。配合根目录的 Dockerfile.arm64-builder 和 docker_build_arm64.sh——用 Docker 交叉编译出 ARM64 产物的意图很明显:你在 x86 开发机上写代码、调试,最终产出要跑在机器人那块 ARM 板子上。
还有个细节:deploy_template/config/policy/ 下的策略目录不叫 gangnam_style,而叫 unistore,而且 exported/ 里同时放了 .onnx 和 .mnn 两份模型。目录名跟上架入口同名,两种模型格式并列——模板在这里给了一个"可替换槽位"的形状。至于两种格式怎么选、什么时候用哪个,仓库文档没说,我不推断。
一个机器人 App,和一个手机 App 差在哪
拆到这里,可以聊本篇真正想聊的问题了。
你手机上装的应用,出了 bug 最坏的结果是什么?闪退。系统弹个提示,你重新打开,日子照过。操作系统给每个应用画了一个沙箱,应用在里面怎么折腾都伤不到别人。
机器人上的应用出 bug 呢?它可能撞到人,可能撞到墙,可能一头栽下去把自己摔坏。
这一条差异,会往下逼出三个平台方必须先解决的问题。
一、能力边界:App 能把机器人控制到什么程度
手机上这个问题用权限系统解决——相机、麦克风、位置,一项一项授权。机器人上要授权什么?"允许移动"?"允许全速移动"?"允许在没人的时候移动"?
这个仓库的模板给了一条侧面线索。看 deploy_template/include/unitree/ 下面:
unitree/dds_wrapper/robots/g1/
├── defines.h
├── g1_app.h
├── g1_pub.h
└── g1_sub.h
unitree/robot/g1/loco/
├── g1_loco_api.hpp
└── g1_loco_client.hpp
一边是 DDS 的发布订阅封装,一边是 loco(locomotion)能力客户端。这正是 unitree_sdk2 那套架构里的两条通信路径——数据流走 Channel,指令走 Client。模板把这两条都带进来了。
留意 g1_app.h 这个文件名:SDK 主仓里对应位置的文件叫 g1.h,这里叫 g1_app.h。命名上多出来的 app 后缀,暗示这是给应用场景准备的一层。具体它跟 SDK 里那个有什么区别,头文件内容我看不到,所以我只指出命名差异,不推断实现差异。
同样地,这里的 loco 只有 api 和 client 两个文件,而 SDK 主仓的 g1/loco/ 下还有一个 g1_loco_error.hpp。差一个文件能说明什么?只能说明模板这一份的裁剪范围不同,不能反推"应用拿不到错误码"——这类反推是最容易出错的地方,仓库没写的事就是没写。
二、资源仲裁:两个 App 同时想控制机器人怎么办
手机上两个应用抢麦克风,系统随便挑一个赢家,最多录音失败。机器人上两个应用抢控制权,一个让它前进一个让它蹲下,指令交替送到执行层——这就不是"失败"的问题了。
好在这件事 SDK 底座已经给了答案:租约(lease)机制。
我们在拆 unitree_sdk2 时见过它——lease_client.hpp、lease_server.hpp,以及请求消息里的 RequestLease_。租约的作用是独占加超时释放:谁拿到租约,谁在有效期内独占控制;持有者崩了或者断线了,租约到期自动回收,别人才能接手。
放到应用生态的语境里再看这个设计,它的分量就不一样了。**单一开发者自己写程序时,租约只是个"别忘了申请"的样板代码;一旦变成多方应用共存的平台,租约就是整个秩序的地基。**没有它,"装两个 App"这件事在物理上就不安全。
我不知道 UniStore 上架的应用具体怎么走这套机制、平台有没有额外的调度层——这些仓库里没写。但一个已经内建了租约的通信底座,显然是做应用平台时的一个有利条件。
三、异常隔离:App 崩了,机器人得能安全停下
这是最硬的一条。手机应用崩溃,进程没了就没了。机器人应用崩溃时,机器人还在物理世界里,还站着,还有惯性。
进程消失的那一刻,谁来接管这具身体?
Docker 在这里的价值就不只是"环境隔离"了。容器天然给了平台一个观察点:进程还在不在、退出码是多少。而模板状态机里那个 State_Passive 和 State_Exit,以及 State_FixStand,正好构成了应用侧的退出路径——从运行状态回到固定站立,或者回到被动状态,再退出。
还有那个 LinearInterpolator.h(线性插值器)。在控制程序里,插值器的典型用途是把状态切换做成一个平滑过渡,而不是让目标值瞬间跳变。关节指令的突变是危险的,无论是从站立切到跳舞,还是从跳舞切回站立,中间都需要一段过渡。一个模板工程里专门放一个插值器,说明作者认为这件事重要到要作为基础设施提供,而不是让每个应用开发者自己想办法。
再加上租约的超时释放:应用进程消失后,它持有的控制权会到期。这三层——容器边界、状态机退出路径、租约超时——合起来才勉强够用。这就是机器人平台比手机平台难的地方:手机上"隔离"是一个纯软件问题,机器人上它是一个软硬件耦合的问题。
生态这件事的现实
聊到这儿,容易滑向"机器人应用生态要起飞了"这类判断。我不打算这么写,因为这个仓库给不出任何支撑这类判断的证据。
客观地说,一个应用生态能立起来,至少要几个条件同时成立:
外面得有足够多的设备在跑。 平台的价值来自装机量,这是最基础的一条。开发者写一个应用,能触达多少台机器人,决定了他愿不愿意开始。
开发者得看得见回报路径。 不管是卖应用、接项目、还是打品牌,总得有个说法。这个仓库里没有任何关于分发、收益、审核的信息,README 只讲到"上传 tar 包到 UniStore"就结束了。
上手成本得压得下来。 这一点上模板仓库确实做了事——把打包约定收窄到两个必需项,把交叉编译打包成一个脚本,给一个可以直接改的完整工程。这些都在降低"从零到能上架"的距离。
现在的状态是什么?就是"有了一个模板仓库和一个示例"。这既不多也不少,它就是一个早期形态。我不评价这个生态能不能成——那需要的是市场数据,不是一个 GitHub 仓库。
倒是有一点可以说得肯定:平台化这个方向本身,逻辑是通的。一台机器人的硬件能力是有限且固定的,但它能做的事情理论上无限。厂商自己写不完所有场景的应用,这是所有硬件平台走向开放的同一个理由。
就算你不做 App,这个模板也值得读
最实际的一点收尾。
绝大多数读到这里的人,短期内不会去 UniStore 上架什么应用。但 deploy_template/ 这个目录,作为一份官方推荐的应用组织方式,价值跟上不上架没关系。
你可以从里面直接抄走的东西:
- 成品与源码分离。
app/放交付物,deploy_template/放源码,两边的config/保持同构。这个分法让"我改了什么"和"我交付了什么"始终对得上。 - 依赖自带。把 DDS 库、推理引擎打进交付物,不指望目标机器上有什么。部署时能省掉大量"在我机器上是好的"。
- 交叉编译容器化。一个
Dockerfile.arm64-builder加一个构建脚本,把"在 x86 上产出 ARM 二进制"这件事变成一条命令。 - 状态机 + 插值器。控制类程序的骨架就是这两样:状态机管"现在该干嘛",插值器管"怎么从上一个状态平滑过到下一个"。
- 控制回路与对外接口分进程。C++ 跑实时控制,Python 跑服务面,两边通过明确的边界交互。
第 5 条尤其值得展开想一想。它和 SDK 示例里 state_machine/ 那个工程用 robot_interface 把 SDK 调用隔离在一层的做法,本质上是同一个思路:把"变化频率不同的东西"分开。控制逻辑很少变,对外接口经常变;把它们缠在一起,改一个就得动另一个。
三件事带走
- 打包约定只有两条:
app/目录和metadata.yaml,加一个"必须在 80 端口提供 HTTP 服务"的硬要求,底层用 Docker,语言不限。 约定越少,越说明平台方想把门槛压低。 - 机器人 App 与手机 App 的差别不在技术栈,在后果。 能力边界、资源仲裁、异常隔离这三个问题,手机平台可以用纯软件手段解决,机器人平台必须软硬件一起想。SDK 里那套租约机制在单人开发时只是样板代码,到了多应用共存的场景才显出它是地基。
- 模板本身就是最好的产出。 成品与源码分离、依赖自带、容器化交叉编译、状态机加插值器、控制与接口分进程——这五条不需要你上架任何应用就能用上。
如果你想继续往这个方向走,宇树二次开发的几条路径那篇梳理了从只调高层接口到直接接管关节控制的完整梯度,可以接着看。这一卷的其他篇目都汇总在宇树与足式机器人专题里。