后装APP运行原理
作者:江访 | 最后修改:2026-06-03
一、架构总览
1.1 概述
- 本文档是基于 exapp v1.0.2,面向客户(后装应用开发者)的框架设计文档。
- 文档主要介绍 exapp 管理层如何启动和关闭后装应用,以及客户如何基于exapp沙箱框架和exwin UI 窗口管理扩展库关联框架开发后装应用。
- 后装应用实际开发过程中所用到的接口主要包含
- airui-图形化开发核心库
- exwin UI 窗口管理扩展库
- 可选使用exapp 后装应用接口
- 根据应用功能需求选择使用核心库和扩展库
- 仅开发后装APP可以先看章节四、客户后装应用开发框架,参考示例:card_duel(卡牌对决)
1.2 整体定位
exapp 是 LuatOS 的扩展应用管理库,它在一套固件上实现了出厂应用(Factory)与后装应用(Aftermarket App)的隔离运行。

1.3 两类应用的角色
| 角色 | 出厂应用(Factory) | 后装应用(Aftermarket) |
|---|---|---|
| 启动方式 | 随固件启动 | 由出厂应用通过 exapp.open(path) 启动 |
| 加载方式 | require("exapp") 获取完整 API |
沙箱环境自动注入 exapp 对象,无需 require |
| 可调用 API | init、open、close、install、uninstall、IOT 登录等全部接口 | 沙箱内 exapp(close、iot_get_account_info、add/list/delete_record) |
| 文件系统 | 全局访问 | 沙箱虚拟路径,隔离读写 |
| 运行环境 | 非沙箱,全局协程 | 沙箱内的独立协程,独立 _ENV |
| 窗口管理 | 直接使用 exwin | 通过沙箱包装的 exwin(自动跟踪窗口生命周期) |
二、后装应用启动流程(exapp 内部逻辑)
2.1 入口:exapp.open(app_path)
出厂应用(如应用商店)调用 exapp.open("/app_store/app_hello/") 启动一个后装应用。后装应用自身不需要也不应该调用 exapp.open 或 exapp.close——这些是出厂应用的职责。
exapp.open(app_path)
│
├─ 检查 app_registry[app_path](防止重复启动)
│ └─ 已存在 → return true
│
├─ 创建 data 目录(app 自身 + 所有存储位置)
│ /app_store/<app_name>/data/
│ /sd/app_store/<app_name>/data/
│ /ram/app_store/<app_name>/data/
│ /little_flash/app_store/<app_name>/data/
│
├─ 注册 app_registry[app_path] = true
│
└─ sys.taskInit(app_task, app_path) → 启动新协程
2.2 app_task 内部九阶段详解
app_task 在后装应用的独立协程中运行,按以下顺序构建沙箱:
阶段一:环境准备
collectgarbage("collect")
local mem_base = collectgarbage("count") -- 内存基准值(用于退出时泄漏检测)
local glob_log, glob_sys = log, sys -- 保存原版引用
local app_name = app_path:match(...) -- 提取应用名,如 "app_hello"
阶段二:沙箱订阅管理器
每个应用维护独立的 subscriptions 表,退出时自动批量取消:
subscriptions = {}
sandbox_subscribe(topic, func) -- sys.subscribe + 记录
sandbox_unsubscribe(topic, func) -- sys.unsubscribe + 移除记录
unsubscribe_all() -- 遍历清理所有订阅(退出时调用)
设计意图:防止应用异常退出后留下"孤儿订阅"导致内存泄漏。
阶段三:沙箱 log 对象
-- log.info("hello") → log.info("[/app_store/app_hello/]", "hello")
sandbox_log = 自动给所有 log 调用添加 [app_path] 前缀
阶段四:沙箱环境表 my_env
my_env = setmetatable({
log = sandbox_log,
package = { loaded = {} } -- 独立的模块缓存
}, { __index = _G }) -- 未命中回退到全局 _G
这是沙箱隔离的核心:my_env 作为应用的 _ENV,所有全局变量先查 my_env 自身,再回退到 _G。
阶段五:注入沙箱 exapp 对象
my_env.exapp = {}
my_env.exapp.close = function() -- 关闭自身
sys.publish(app_path .. "_close_req", "yes")
end
my_env.exapp.iot_get_account_info = ... -- IOT 登录状态查询
my_env.exapp.iot_get_auth_headers = ... -- IOT 认证头
my_env.exapp.add_record = ... -- 云端数据库写入(自动注入 app_id)
my_env.exapp.list_record = ... -- 云端数据库查询
my_env.exapp.delete_record = ... -- 云端数据库删除
后装应用在代码中直接使用 exapp.xxx(),无需 require。
阶段六:沙箱 require 函数
require(name)
├─ 检查 my_env.package.loaded[name](应用内缓存)
├─ 检查是否 LuatOS 扩展库(43 个)→ 从 _G 或全局 require 加载
├─ 普通模块 → 从应用目录加载,优先 user/ 再 libs/
│ 搜索路径:app_path/user/name.lua → app_path/user/name.luac
│ → app_path/libs/name.lua → app_path/libs/name.luac
└─ 加载后 debug.setupvalue(chunk, 1, my_env) 确保模块在沙箱中运行
注意:libs/ 和 user/ 目录不能包含子目录,require 只在扁平目录中搜索。
阶段七:路径解析系统
所有文件 I/O 操作的虚拟路径被自动转换为实际路径:
读操作路径映射
读操作仅做虚拟路径到物理路径的转换(resolve_file / resolve_dir),不检查写入权限:
| 沙箱内使用的路径 | 解析为实际路径 | 说明 |
|---|---|---|
/luadb/main.lua |
<app_path>/main.lua → user/ → libs/ |
Lua 脚本,按优先级搜索 |
/luadb/enemy1.png |
<app_path>/res/enemy1.png |
资源文件(非 .lua/.luac) |
/luadb/icon.png |
<app_path>/icon.png |
图标,特殊处理 |
/luadb/meta.json |
<app_path>/meta.json |
元信息,特殊处理 |
/ram/log.txt |
/ram/app_store/<app_name>/data/log.txt |
RAM 应用私有区 |
/sd/config.json |
/sd/app_store/<app_name>/data/config.json |
TF 卡应用私有区 |
/little_flash/data.bin |
/little_flash/app_store/<app_name>/data/data.bin |
外挂 Flash 应用私有区 |
/settings/save.json |
<mount_point>app_store/<app_name>/data/settings/save.json |
内置 Flash 数据区(跟随安装位置) |
写操作路径映射
写操作先做路径映射,再经 check_write 权限检查(resolve_write),两关任一失败则拒绝:
| app 代码写的路径 | 包装后的实际路径 | 说明 |
|---|---|---|
/hxxt.dat |
<mount_point>app_store/<app_name>/data/hxxt.dat |
写到 app 私有 data 目录 |
/ram/log.txt |
/ram/app_store/<app_name>/data/log.txt |
写到 RAM 应用私有区 |
/sd/config.json |
/sd/app_store/<app_name>/data/config.json |
写到 TF 卡应用私有区 |
/little_flash/data.bin |
/little_flash/app_store/<app_name>/data/data.bin |
写到外挂 Flash 应用私有区 |
其中 <mount_point> 跟随 app 的安装位置:装在 /app_store/ 则为 /,装在 /little_flash/app_store/ 则为 /little_flash/,装在 /sd/app_store/ 则为 /sd/。
写入权限规则(check_write):
- 允许写入:应用自身 data/ 子目录及各存储的私有 data 区
- 禁止写入:libs/、user/、res/ 目录(只读资源)
- 禁止覆盖:main.lua、meta.json、icon.png(受保护文件)
阶段八:沙箱库包装
全局库通过包装器确保路径隔离和安全访问:
| 库 | 包装要点 |
|---|---|
| io | 全面重写——open/exists/fileSize/readFile/writeFile/mkdir/rmdir/lsdir 全部路径映射 |
| airui | 三阶段管道:路径解析 → 坐标缩放(适配不同分辨率)→ 组件代理(拦截 set_src/set_pos 等) |
| exwin | 窗口生命周期跟踪——所有窗口关闭时自动退出应用 |
| fskv | 键名自动加 <app_name>_ 前缀,不同应用数据隔离;退出时自动清理 |
| audio | play/record 路径自动映射 |
| camera | capture 保存路径自动映射 |
| http/httpplus | 下载/上传路径自动映射 |
| ftp/fota/ymodem/xmodem | 文件传输路径自动映射 |
阶段九:加载并执行 main.lua
local _ENV = my_env -- 切换到沙箱环境
local f, err = loadfile(app_path.."main.lua") -- 加载入口文件
-- 失败 → sandbox_cleanup → 协程退出
debug.setupvalue(f, 1, _ENV) -- main.lua 环境设为沙箱
local ok, result = xpcall(f, eh) -- xpcall 保护执行
-- 异常 → sandbox_cleanup → 协程退出
sys.waitUntil(app_path.."_close_req") -- 等待关闭请求
-- 收到关闭 → sandbox_cleanup → 协程退出
2.3 启动时序总览
出厂应用 exapp 管理层 后装应用沙箱
│ │ │
│── exapp.open(app_path) ──────▶ │ │
│ │── 创建 data 目录 │
│ │── sys.taskInit(app_task) │
│◀────── return true ─────────────│ │
│ │ │
│ │ [新协程] 构建沙箱环境 │
│ │ • 订阅管理器 │
│ │ • sandbox_log │
│ │ • my_env(沙箱环境表) │
│ │ • my_env.exapp(注入) │
│ │ • my_env.require │
│ │ • 路径解析函数族 │
│ │ • 包装 io/audio/airui/… │
│ │ │
│ │── loadfile(main.lua) ───────▶ │
│ │ │── main.lua 执行
│ │ │ require 模块
│ │ │ exwin.open(…)
│ │ │ 创建 UI 窗口
│ │ │ sys.run()
│ │ │ 进入事件循环
│ │ │
│ │── sys.waitUntil(close_req) │(主流程等待)
三、后装应用关闭流程
3.1 后装应用侧:exwin.close(win_id)
后装应用不调用 exapp.close(),而是通过 exwin.close(win_id) 关闭窗口。沙箱包装的 exwin 自动跟踪窗口数量,当所有窗口关闭后自动退出:
后装应用调用 exwin.close(win_id)
│
├─ 全局 exwin.close(win_id) → 实际关闭窗口
├─ 从沙箱 win_ids 列表中移除
└─ check_windows()
└─ #win_ids == 0
└─ my_env.exapp.close()
└─ sys.publish(app_path.."_close_req", "yes")
→ 触发 app_task 中的 sys.waitUntil,进入清理
同样,exwin.return_idle() 也会清空窗口记录并触发自动退出。
3.2 出厂应用侧:exapp.close(app_path)
出厂应用也可以主动关闭后装应用(例如应用商店的"强制停止"功能):
exapp.close(app_path)
└─ sys.publish(app_path.."_close_req", "yes")
→ 触发 app_task 中的 sys.waitUntil,进入清理
3.3 sandbox_cleanup 清理序列
无论正常退出还是异常崩溃,都执行相同的清理流程:
sandbox_cleanup(app_path, my_env, unsubscribe_all, mem_base)
│
├─ 1. unsubscribe_all()
│ → 遍历 subscriptions,逐个 sys.unsubscribe(防止孤儿订阅)
│
├─ 2. 清理 package.loaded
│ → 遍历 my_env.package.loaded,逐个置 nil(释放模块引用)
│
├─ 3. cleanup_env(my_env)
│ → 清理沙箱环境表中的所有键
│
├─ 4. app_registry[app_path] = nil
│ → 从运行注册表中移除,允许再次启动
│
├─ 5. collectgarbage("collect")
│ → 强制 GC 回收
│
└─ 6. 内存泄漏检测
→ mem_after - mem_base 对比(当前静默,可开启 warn 日志)
3.4 异常处理
main.lua 加载失败 → sandbox_cleanup → 协程退出(不影响其他应用)
main.lua 运行时异常
→ xpcall 捕获 → sandbox_cleanup → 协程退出
注意:回调函数(定时器、事件监听)中的异常可能运行在全局环境,
无法被沙箱 xpcall 捕获,可能导致系统级影响。
四、客户后装应用开发框架
4.1 应用打包结构
一个标准的后装应用 ZIP 包结构如下:
example1.zip ← ZIP 文件名可自定义
└── example1/ ← 【必需】内部包含一个同名文件夹
├── main.lua ← 【必需】应用入口
├── icon.png ← 【推荐】应用图标
├── meta.json ← 【必需】应用元数据
├── libs/ ← 扩展库脚本(不能包含子目录)
│ └── xxx.lua
├── user/ ← 用户自定义脚本(不能包含子目录)
│ └── card_duel_win.lua
├── res/ ← 资源文件(不能包含子目录)
│ ├── enemy1.png
│ ├── player.png
│ └── win.png
└── data/ ← 【运行时自动创建】数据存储
└── ...
重要约束: - ZIP 内部必须有一个文件夹包裹所有内容(文件夹名即应用标识 app_name) - libs/、user/、res/ 三个目录都是扁平结构,不能再包含子目录 - data/ 目录由框架在首次启动时自动创建,打包时不需要放入
4.2 横竖屏与分辨率概念
目前引擎主机显示设备分辨率有四种:320×480(竖屏)、480×800(竖屏)、720×1280(竖屏)、1024×600(横屏)。
4.2.1 横竖屏概念
显示设备分辨率由显示初始化代码 lcd_drv 的参数决定: - w < h → 竖屏显示设备 - w > h → 横屏显示设备
应用分辨率由后装应用 meta.json 中的 resolution 字段声明(meta.json 各字段的完整说明见 4.3 meta.json 规范):
- w < h → 竖屏后装应用
- w > h → 横屏后装应用
4.2.2 应用固定分辨率布局概念
后装应用中UI组件的 x、y、h、w 这些参数如果是固定的(如 x=10, y=10, h=20, w=30),则该应用为固定分辨率应用,meta.json 中 display_zoom 应填 "fixed_resolution"(或不填,效果相同)。
4.2.3 固定分辨率应用的缩放机制
display_zoom 填 "fixed_resolution"( 为固定分辨率模式时),exapp 会根据显示设备分辨率与应用分辨率自动计算缩放系数:
水平缩放系数 = 显示设备宽 / 应用分辨率宽
垂直缩放系数 = 显示设备高 / 应用分辨率高
竖屏后装应用既能在竖屏显示设备上运行,也能在横屏显示设备上运行,exapp 在两种情况下都会自动缩放。但缩放效果差异很大:
在竖屏显示设备上运行竖屏应用(缩放系数接近):
| 应用分辨率 | 显示设备分辨率 | 水平缩放 | 垂直缩放 | 效果 |
|---|---|---|---|---|
| 320×480 | 320×480 | 1.0 | 1.0 | 不缩放,完美匹配 |
| 320×480 | 480×800 | 1.5 | 1.67 | 等比例放大,效果良好 |
| 320×480 | 720×1280 | 2.25 | 2.67 | 接近等比放大,效果良好 |
在横屏显示设备上运行竖屏应用(缩放系数差异大):
| 应用分辨率 | 显示设备分辨率 | 水平缩放 | 垂直缩放 | 效果 |
|---|---|---|---|---|
| 320×480 | 1024×600 | 3.2 | 1.25 | 横向过度拉伸,纵向轻微拉伸,画面严重变形 |
直观来说:一个在竖屏应用中设计为"又瘦又高"的人物,在横屏显示设备上会变成"又胖又矮"。
总结: - 竖屏显示设备 + 竖屏后装应用:显示效果没问题 - 横屏显示设备 + 横屏后装应用:显示效果没问题 - 竖屏显示设备 + 横屏后装应用:能显示,但整体效果较差 - 横屏显示设备 + 竖屏后装应用:能显示,但整体效果较差
4.2.4 开发建议
| 目标运行环境 | 建议 |
|---|---|
| 只需要在竖屏显示设备上运行 | 只开发竖屏后装应用即可 |
| 只需要在横屏显示设备上运行 | 只开发横屏后装应用即可 |
| 需要在竖屏和横屏显示设备上都运行 | 分别开发竖屏版和横屏版两种后装应用。设备端根据自身显示方向,下载安装对应方向的应用,这样效果最好 |
4.2.5 固定分辨率应用显示效果示意
exapp 自动缩放示意如下:

4.3 meta.json 规范
meta.json 是后装应用的核心配置文件。关于分辨率和显示方向的概念详见 4.2 横竖屏与分辨率概念。
以下为各字段的完整说明:
| 字段名 | 数据类型 | 是否必选 | 描述 |
|---|---|---|---|
app_name_cn |
string | 是 | 应用中文名,在应用商店内展示,字母间使用_连接,不可使用-连接。 |
app_name_en |
string | 是 | 应用英文名,用作安装目录名。 |
publish_time |
string | 是 | 发布时间,格式 YYYY-MM-DD HH:MM:SS。 |
description |
string | 是 | 应用描述文本,在应用商店内展示。 |
zip_size_kb |
string | 是 | 安装包大小(KB),可随意填写值,上传应用压缩包时服务器会写入正确的值,用于设备端判断 /ram 目录剩余空间是否足够下载。空间不足时提示用户,不发起下载。 |
origin_size_kb |
string | 是 | 解压后大小(KB),可随意填写值,上传应用压缩包时服务器会写入正确的值,用于设备端判断根目录剩余空间是否足够解压。空间不足时提示用户,不进行下载和解压。 |
supported_models |
object | 是 | 适用设备型号。填写具体型号(如 "Air8101"、"Air780EHM")表示只有该型号可用;目前服务端未启用此字段的校验复制demo中的内容即可,暂不影响应用分发。 |
min_firmware_version |
int | 是 | 最低固件版本要求。目前服务端未启用此字段的校验,暂不影响应用分发。 |
firmware_id |
int | 是 | 固件 ID。填写具体 ID 表示只有匹配此 ID 的固件可用;填写 "all" 表示不限制。目前服务端未启用此字段的校验,暂不影响应用分发。 |
resolution |
string | 是 | 应用分辨率,格式 宽x高(如 "320x480")。宽>高为横屏应用,宽<高为竖屏应用。exapp 启动应用时如果display_zoom参数不是"adaptive"则读取此字段,与显示设备分辨率对比计算缩放系数(详见 4.2.3 固定分辨率应用的缩放机制)。 |
category |
string | 是 | 应用类别(中文),分为:全部、游戏、工具、学习、通信、工业。用于应用市场分类筛选。 |
version |
string | 是 | 应用版本号,格式 1.0.0(不带 V 前缀)。设备端已安装版本低于服务端版本时,提示更新。 |
display_zoom |
string | 否 | 显示缩放模式。填 "fixed_resolution" 则exapp计算resolution参数进行自动缩放,缩放机制详见 4.2.3 固定分辨率应用的缩放机制。填写 "adaptive" 表示应用自适应,exapp 跳过计算resolution参数进行缩放,按应用实际参数进行显示; |
安装后由 exapp 自动补充的字段(开发者不需要填写):
- install_time:安装时间戳(Unix timestamp)
- total_downloads:累计下载次数
- appid:服务端分配的应用 ID(用于云端数据库隔离)
4.4 main.lua 编写规范
最小示例
下面的示例演示了后装应用的完整生命周期:打开窗口、在窗口中放置一个"关闭应用"按钮、点击按钮关闭窗口后自动退出应用。
main.lua — 应用入口:
PROJECT = "MY_APP"
VERSION = "1.0.0"
log.info("main", PROJECT, VERSION)
-- 加载窗口模块
require "my_win"
-- 发布消息触发窗口创建
sys.publish("OPEN_MY_WIN")
-- 进入事件循环(必需)
sys.run()
关键设计要点:
- main.lua 先加载后发布:
require "my_win"加载窗口模块时,模块内的sys.subscribe("OPEN_MY_WIN", ...)已完成订阅注册,之后sys.publish("OPEN_MY_WIN")发布消息才能被正确接收。顺序不能颠倒,否则消息发出时还没有订阅者,窗口不会被打开。 - 主容器统一管理子组件:以
main_container作为窗口的根容器,所有 UI 子组件(label、button、image 等)的parent都指向它。销毁时只需main_container:destroy(),AirUI 会自动递归销毁所有子组件,无需逐个清理。 - 定时器必须手动停止:
main_container:destroy()只销毁 UI 组件,不会停止定时器。如果有sys.timerLoopStart()或sys.timerStart(),必须在on_destroy中调用sys.timerStop()停止,否则定时器回调可能访问已销毁的组件导致异常。
user/my_win.lua — 窗口模块(被 main.lua require 加载):
-- my_win.lua - 主窗口模块
local win_id = nil
local main_container = nil
local update_timer_id = nil -- 定时器 ID
-- 构建 UI
local function create_ui()
-- 创建主容器作为最底层,所有子组件都加到主容器内
main_container = airui.container({
parent = airui.screen,
x = 0, y = 0, w = 320, h = 480,
color = 0x111827
})
-- 标题文字(parent 指向 main_container)
airui.label({
parent = main_container,
text = "Hello World",
x = 60, y = 120, w = 200, h = 50,
font_size = 24,
color = 0xD8DCE6
})
-- 关闭按钮(parent 指向 main_container)
airui.button({
parent = main_container,
x = 110, y = 220, w = 100, h = 40,
text = "关闭应用",
font_size = 14,
style = { bg_color = 0x2563EB, text_color = 0xFFFFFF, radius = 8, border_width = 0 },
on_click = function()
exwin.close(win_id) -- 关闭当前窗口
end
})
end
-- 定时刷新 UI(示例:每秒更新时间显示)
local function update_tick()
-- 定时器回调中的逻辑...
end
-- 窗口创建回调
local function on_create()
create_ui()
-- 启动定时器
update_timer_id = sys.timerLoopStart(update_tick, 1000)
log.info("my_win", "窗口创建")
end
-- 窗口销毁回调:先停止定时器,再销毁主容器
local function on_destroy()
-- 1. 停止定时器(必须手动停止,destroy 不会自动停止)
if update_timer_id then
sys.timerStop(update_timer_id)
update_timer_id = nil
end
-- 2. 销毁主容器(自动递归销毁内部所有子组件)
if main_container then
main_container:destroy()
main_container = nil
end
win_id = nil
log.info("my_win", "窗口销毁")
end
-- 打开窗口的处理函数
local function open_handler()
win_id = exwin.open({
on_create = on_create,
on_destroy = on_destroy,
on_get_focus = function() end,
on_lose_focus = function() end,
})
end
sys.subscribe("OPEN_MY_WIN", open_handler)
生命周期关键点:
- on_create:创建 main_container 作为根容器 → 以 main_container 为 parent 构建子组件 → 启动定时器
- on_destroy:先停止定时器(防止回调访问已销毁组件)→ 再 main_container:destroy()(自动销毁所有子组件)→ 释放引用
- 关闭按钮的 on_click 调用 exwin.close(win_id) → 触发 on_destroy → 窗口从栈移除 → 框架检测无窗口 → 发布 _close_req → 应用退出
完整示例:卡牌对决(card_duel)模式总结
PROJECT = "CARD_DUEL"
VERSION = "001.000.000"
log.info("main", PROJECT, VERSION)
-- 1. 使用 require 加载用户模块(自动从 user/ 目录加载)
require "card_duel_win"
-- 2. 发布消息触发窗口创建
sys.publish("OPEN_CARD_DUEL_WIN")
-- 3. 进入事件循环(必需)
sys.run()
card_duel 的用户模块(user/card_duel_win.lua)体现了标准开发模式。注意两个核心原则:
- 主容器统一管理:所有 UI 子组件(battle 容器、手牌、按钮等)的
parent都指向main_container。销毁时只需main_container:destroy(),AirUI 自动递归销毁内部所有子组件。 - 定时器先于 UI 销毁:
on_destroy中先sys.timerStop()停止定时器,避免回调访问已销毁的 UI 组件,再main_container:destroy()清理 UI。
exwin 生命周期回调:
function on_create()
-- 创建主容器作为最底层
main_container = airui.container({parent = airui.screen, x = 0, y = 0, w = 320, h = 480, color = 0x111827})
-- 所有子组件 parent 都指向 main_container
-- ... 构建 battle、hand、action bar 等子组件 ...
-- 启动定时器
game_timer_id = sys.timerLoopStart(tick, 50)
end
function on_destroy()
-- 1. 先停止定时器(防止回调访问已销毁的 UI 组件)
if game_timer_id then
sys.timerStop(game_timer_id)
game_timer_id = nil
end
-- 2. 再销毁主容器(自动递归销毁内部所有子组件)
if main_container then
main_container:destroy()
main_container = nil
end
win_id = nil
end
function on_get_focus() end
function on_lose_focus() end
win_id = exwin.open({
on_create = on_create,
on_destroy = on_destroy,
on_get_focus = on_get_focus,
on_lose_focus = on_lose_focus
})
按钮触发关闭窗口(card_duel 实际代码中的模式):
-- 在 create_ui 中创建一个"关闭"按钮
airui.button({
parent = main_container,
x = 272, y = 426, w = 48, h = 20,
text = "关闭",
font_size = 14,
style = { text_color = 0x6B7280, bg_color = 0x444444, radius = 3, border_width = 0 },
on_click = function()
exwin.close(win_id) -- 传入 exwin.open() 返回的窗口 ID
end
})
-- 也可以在 UI 外定义关闭函数供多处复用
on_close = function()
if win_id then
exwin.close(win_id)
end
end
-- 按钮绑定该函数
airui.button({
parent = main_container,
text = "关闭",
on_click = on_close
})
资源路径使用:
-- 使用 /luadb/ 虚拟路径引用 res/ 下的资源
airui.image({src = "/luadb/enemy1.png", ...}) -- 映射到 <app_path>/res/enemy1.png
airui.image({src = "/luadb/win.png", ...})
airui.image({src = "/luadb/lose.png", ...})
airui.image({src = "/luadb/victory.png", ...})
airui.image({src = "/luadb/player.png", ...})
文件读写(沙箱自动映射路径):
-- 读取资源(只读)
local data = io.readFile("/luadb/config.txt") -- 映射到 <app_path>/res/config.txt
-- 写入数据(自动映射到 data/ 目录)
io.writeFile("/settings.json", json.encode({score = 999}))
-- 实际写入 <app_path>/data/settings.json
云端数据库操作:
local info = exapp.iot_get_account_info()
if not info.is_guest then
exapp.list_record(
{cls = 1, sort = "i1 desc", size = 20},
function(success, data)
-- 处理返回数据
end
)
end
4.5 沙箱中可用的全局对象
后装应用在沙箱中可以直接使用以下对象(无需 require):
| 对象 | 说明 | 备注 |
|---|---|---|
log |
日志输出 | 自动添加 [app_path] 前缀 |
sys |
系统接口(subscribe/publish/timerStart/taskInit等) | subscribe 会自动记录 |
exapp |
应用管理对象(沙箱版) | close / iot_get_account_info / add_record / list_record / delete_record |
io |
文件 I/O | 路径自动映射 + 写权限检查 |
airui |
UI 组件库 | 路径映射 + 坐标缩放 + 组件代理 |
exwin |
窗口管理 | 窗口生命周期跟踪 + 无窗口时自动退出 |
fskv |
键值存储 | 键名自动加 <app_name>_ 前缀隔离 |
require |
模块加载 | 从应用目录加载 + 沙箱环境隔离 |
json |
JSON 编解码 | 全局透传 |
audio |
音频播放/录音 | 路径自动映射 |
camera |
拍照 | 保存路径自动映射 |
http / httpplus |
网络请求 | 下载/上传路径自动映射 |
fs |
文件系统统计 | fsize 复用 io 映射 |
ftp / fota / ymodem / xmodem |
文件传输 | 路径自动映射 |
4.6 窗口生命周期
后装应用的核心交互模型是"窗口驱动":
exwin.open()
│
├─ on_create() ← 窗口创建,初始化数据和 UI
├─ on_get_focus()← 窗口获得焦点
│ …
├─ on_lose_focus()← 窗口失去焦点
│ …
└─ on_destroy() ← 窗口关闭,清理资源
exwin.close(win_id) → 触发 on_destroy → 窗口计数 -1
└─ 所有窗口关闭后 → 自动退出应用
关键原则:
- 后装应用通过 exwin.open() 创建窗口,通过 exwin.close(win_id) 关闭窗口
- 不要调用 exapp.open() 或 exapp.close()——这些是出厂应用的接口
- 在 on_destroy 中清理定时器、销毁 UI、释放资源
- main.lua 末尾需要调用 sys.run() 进入事件循环
4.7 fskv 键名隔离
沙箱包装的 fskv 会自动添加 app_name_ 前缀,确保不同应用的数据隔离:
-- app_hello 中调用
fskv.set("score", 100)
-- 实际键名:app_hello_score
-- 读取也自动添加前缀
fskv.get("score")
-- 实际读取:app_hello_score
-- 应用退出时自动清理所有以 app_hello_ 开头的 fskv 键
4.8 开发注意事项
4.8.1 入口与加载顺序
- main.lua 先 require 后 publish:窗口模块在 require 时完成
sys.subscribe注册,之后才能sys.publish触发窗口打开。顺序颠倒会导致消息发出时没有订阅者,窗口无法打开。同理,如果多个窗口模块,所有 require 都应在 publish 之前完成。 - PROJECT / VERSION 写在 main.lua 顶部:这不是框架强制要求,但是一种推荐约定,便于日志追踪和问题定位。
4.8.2 UI 与容器管理
- 主容器统一管理子组件:用一个
main_container作为根容器(parent = airui.screen),所有子组件(label、button、image、子容器等)的parent都指向它。销毁时只需main_container:destroy()即可递归清理全部子组件,无需逐个销毁。 - on_create 和 on_destroy 都是必需的:
exwin.open()的 config 表中on_create和on_destroy必须提供,否则会报错重启。on_get_focus和on_lose_focus可选。 - on_destroy 清理顺序:先定时器,后 UI:定时器回调中可能访问 UI 组件(如更新 label 文字),如果先销毁 UI 再停止定时器,定时器回调触发时会访问已销毁的组件导致异常。正确顺序:
sys.timerStop()→main_container:destroy()→ 释放引用。 - exwin.open() 返回的 win_id 必须保存:
win_id是后续关闭窗口的唯一标识。如果丢失了 win_id,就无法调用exwin.close(win_id)正常关闭窗口。 - 避免只开不关窗口:持续
exwin.open()而不exwin.close()会让窗口栈不断增长,内存持续增加。推荐退出窗口时使用exwin.close(win_id)清理 UI 内存。如果要保持窗口数据为最新,可以将动态内容存入变量,通过sys.subscribe或定时器更新,下次打开窗口时从变量读取。 - 在定时器/异步回调中判断窗口是否活动:调用
exwin.is_active(win_id)判断当前窗口是否仍为栈顶活动窗口,避免操作已被覆盖的非活动窗口控件。
4.8.3 文件与路径
- 路径使用虚拟路径:读写文件用
/luadb/xxx.png(资源)、/ram/data.txt(RAM 数据)、/settings.json(Flash 数据)等,不要直接拼接 app_path。沙箱会自动映射到正确的物理路径。 - libs/user/res 不能有子目录:require 和路径解析只在扁平目录中搜索,不支持嵌套。例如
user/subdir/xxx.lua无法被require "subdir.xxx"加载。 - 不要写 main.lua / meta.json / icon.png:这三个文件受沙箱写保护,尝试写入会被
check_write拒绝。应用数据写入/data/区或通过 fskv 存储。
4.8.4 沙箱与生命周期
- 不要调用 exapp.open / exapp.close:这些是出厂应用管理后装应用生命周期的接口。后装应用通过
exwin.open()创建窗口、exwin.close(win_id)关闭窗口,框架自动跟踪窗口数量并在无窗口时退出。 - 不要覆盖 _G 中的全局变量:沙箱环境通过
__index = _G回退读取全局变量,如果在沙箱中直接对全局变量赋值(如io = something),会写到 my_env 表中覆盖沙箱包装的 io 库,导致路径映射失效。 - 减少应用内调用 sys.taskInit:沙箱已经运行在独立协程中,main.lua 的主流程就是应用的"主线程"。如有定时任务,优先使用
sys.timerLoopStart()/sys.timerStart()。
4.8.5 网络与数据
- 云端操作需要 IOT 登录:
exapp.add_record()/exapp.list_record()/exapp.delete_record()依赖 IOT 登录状态。游客模式(is_guest == true)下这些接口可能受限或失败。建议调用前先exapp.iot_get_account_info()检查登录状态。 - 网络未就绪时云端操作会失败:建议在调用云端接口前检查网络状态,或通过
sys.subscribe("WIFI_CONNECTED", ...)在网络恢复后重试。 - fskv 键名自动加前缀隔离:不同后装应用的 fskv 数据通过
app_name_前缀隔离,不会互相覆盖。应用退出时框架自动清理本应用的所有 fskv 键。不要依赖其他应用的 fskv 数据。
五、应用打包与发布
5.1 打包示例

- 找到APP文件夹所在目录,右击应用总目录,压缩为.zip格式文件

5.2 应用发布渠道
5.3 安装后的目录
云端安装后,应用在设备上的实际目录结构:
/app_store/example1/ ← app_name 即 ZIP 内的主文件夹名
├── main.lua
├── icon.png
├── meta.json ← 安装后补充了 install_time / appid 等字段
├── libs/
├── user/
├── res/
└── data/ ← 首次启动时自动创建
对于多存储设备:
/sd/app_store/example1/ ← TF 卡上的应用(结构同上)
/little_flash/app_store/example1/ ← 外挂 Flash 上的应用(结构同上)
六、出厂应用的职责
出厂应用(如应用商店)负责后装应用的全生命周期管理:
local exapp = require("exapp")
-- 1. 初始化(扫描已安装应用 + 挂载外部存储 + IOT 自动登录)
exapp.init()
-- 2. 启动后装应用
exapp.open("/app_store/card_duel/")
-- 3. 强制关闭后装应用(通常由用户操作触发)
exapp.close("/app_store/card_duel/")
-- 4. 查询运行状态
local running = exapp.is_running("/app_store/card_duel/")
local running_list = exapp.list_running()
-- 5. 获取已安装列表
local installed, count = exapp.list_installed()
-- 6. 云端应用管理
exapp.get_app_list({category = "全部", sort = "recommend"})
exapp.install_remote_app(aid, url, app_name, category, sort)
exapp.uninstall_remote_app(aid, category, sort)
exapp.update_remote_app(aid, url, app_name, category, sort)
-- 7. IOT 账号管理
exapp.iot_login(account, password)
exapp.iot_logout()
七、附录:API 速查
-
后装应用侧(沙箱内直接调用,无需 require)
-
出厂应用侧(require("exapp") 后调用),目前mian.lua中已经调用
消息体系
出厂应用可监听的消息:
APP_STORE_LIST_UPDATED— 应用列表已更新APP_STORE_ACTION_DONE— 安装/卸载/更新已完成APP_STORE_PROGRESS— 下载安装进度APP_STORE_ERROR— 错误通知APP_STORE_INSTALLED_UPDATED— 已安装列表已变更APP_STORE_ICON_READY— 图标下载完成
出厂应用可发布的消息:
APP_STORE_GET_LIST— 请求获取应用列表APP_STORE_INSTALL— 请求安装应用APP_STORE_UNINSTALL— 请求卸载应用APP_STORE_UPDATE— 请求更新应用APP_STORE_SYNC_INSTALLED— 请求同步已安装信息