跳转至

后装APP运行原理

作者:江访 | 最后修改:2026-06-03

一、架构总览

1.1 概述


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.openexapp.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.luauser/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.luameta.jsonicon.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()

关键设计要点

  1. main.lua 先加载后发布require "my_win" 加载窗口模块时,模块内的 sys.subscribe("OPEN_MY_WIN", ...) 已完成订阅注册,之后 sys.publish("OPEN_MY_WIN") 发布消息才能被正确接收。顺序不能颠倒,否则消息发出时还没有订阅者,窗口不会被打开。
  2. 主容器统一管理子组件:以 main_container 作为窗口的根容器,所有 UI 子组件(label、button、image 等)的 parent 都指向它。销毁时只需 main_container:destroy(),AirUI 会自动递归销毁所有子组件,无需逐个清理。
  3. 定时器必须手动停止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)体现了标准开发模式。注意两个核心原则:

  1. 主容器统一管理:所有 UI 子组件(battle 容器、手牌、按钮等)的 parent 都指向 main_container。销毁时只需 main_container:destroy(),AirUI 自动递归销毁内部所有子组件。
  2. 定时器先于 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 入口与加载顺序

  1. main.lua 先 require 后 publish:窗口模块在 require 时完成 sys.subscribe 注册,之后才能 sys.publish 触发窗口打开。顺序颠倒会导致消息发出时没有订阅者,窗口无法打开。同理,如果多个窗口模块,所有 require 都应在 publish 之前完成。
  2. PROJECT / VERSION 写在 main.lua 顶部:这不是框架强制要求,但是一种推荐约定,便于日志追踪和问题定位。

4.8.2 UI 与容器管理

  1. 主容器统一管理子组件:用一个 main_container 作为根容器(parent = airui.screen),所有子组件(label、button、image、子容器等)的 parent 都指向它。销毁时只需 main_container:destroy() 即可递归清理全部子组件,无需逐个销毁。
  2. on_create 和 on_destroy 都是必需的exwin.open() 的 config 表中 on_createon_destroy 必须提供,否则会报错重启。on_get_focuson_lose_focus 可选。
  3. on_destroy 清理顺序:先定时器,后 UI:定时器回调中可能访问 UI 组件(如更新 label 文字),如果先销毁 UI 再停止定时器,定时器回调触发时会访问已销毁的组件导致异常。正确顺序:sys.timerStop()main_container:destroy() → 释放引用。
  4. exwin.open() 返回的 win_id 必须保存win_id 是后续关闭窗口的唯一标识。如果丢失了 win_id,就无法调用 exwin.close(win_id) 正常关闭窗口。
  5. 避免只开不关窗口:持续 exwin.open() 而不 exwin.close() 会让窗口栈不断增长,内存持续增加。推荐退出窗口时使用 exwin.close(win_id) 清理 UI 内存。如果要保持窗口数据为最新,可以将动态内容存入变量,通过 sys.subscribe 或定时器更新,下次打开窗口时从变量读取。
  6. 在定时器/异步回调中判断窗口是否活动:调用 exwin.is_active(win_id) 判断当前窗口是否仍为栈顶活动窗口,避免操作已被覆盖的非活动窗口控件。

4.8.3 文件与路径

  1. 路径使用虚拟路径:读写文件用 /luadb/xxx.png(资源)、/ram/data.txt(RAM 数据)、/settings.json(Flash 数据)等,不要直接拼接 app_path。沙箱会自动映射到正确的物理路径。
  2. libs/user/res 不能有子目录:require 和路径解析只在扁平目录中搜索,不支持嵌套。例如 user/subdir/xxx.lua 无法被 require "subdir.xxx" 加载。
  3. 不要写 main.lua / meta.json / icon.png:这三个文件受沙箱写保护,尝试写入会被 check_write 拒绝。应用数据写入 /data/ 区或通过 fskv 存储。

4.8.4 沙箱与生命周期

  1. 不要调用 exapp.open / exapp.close:这些是出厂应用管理后装应用生命周期的接口。后装应用通过 exwin.open() 创建窗口、exwin.close(win_id) 关闭窗口,框架自动跟踪窗口数量并在无窗口时退出。
  2. 不要覆盖 _G 中的全局变量:沙箱环境通过 __index = _G 回退读取全局变量,如果在沙箱中直接对全局变量赋值(如 io = something),会写到 my_env 表中覆盖沙箱包装的 io 库,导致路径映射失效。
  3. 减少应用内调用 sys.taskInit:沙箱已经运行在独立协程中,main.lua 的主流程就是应用的"主线程"。如有定时任务,优先使用 sys.timerLoopStart() / sys.timerStart()

4.8.5 网络与数据

  1. 云端操作需要 IOT 登录exapp.add_record() / exapp.list_record() / exapp.delete_record() 依赖 IOT 登录状态。游客模式(is_guest == true)下这些接口可能受限或失败。建议调用前先 exapp.iot_get_account_info() 检查登录状态。
  2. 网络未就绪时云端操作会失败:建议在调用云端接口前检查网络状态,或通过 sys.subscribe("WIFI_CONNECTED", ...) 在网络恢复后重试。
  3. 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 — 请求同步已安装信息