A
Air官方
【Air 系列 · 鸿蒙 PC 适配全记录】23 / 56
■ 应用:AirMidi(from Neothesia)(AirMidi)
■ 定位:Neothesia 移植,MIDI 钢琴瀑布流可视化
■ 上游:Neothesia | 路线:Rust wgpu 引擎直连 | 版本:1.2.0
■ 开源: https://gitcode.com/Andy88/Harmony-Neothesia
■ 市场状态:已上架 AGC 应用页: https://appgallery.huawei.com/app/6917616744301212230
■ 官网(免费下载): https://www.shunidc.cn/
————————————————————
开源鸿蒙 PC 社区: https://harmonypc.csdn.net/
PC 社区项目申请: https://atomgit.com/OpenHarmonyPCDeveloper
适配开源地址:https://gitcode.com/Andy88/Harmony-Neothesia
环境搭建文章:https://blog.csdn.net/weixin_52908342/article/details/161343743
▍一、为什么要适配 AirMidi(Neothesia)
Neothesia 是一个开源的 MIDI 钢琴瀑布流可视化应用:MIDI 文件的每个音符以彩色长条从屏幕上方落下,到底部时对应的琴键高亮发声,是钢琴练习与演奏展示场景里辨识度很高的一类工具。上游用 Rust 编写,渲染层是 wgpu 30 自绘,合成器是纯 Rust 的 oxisynth,加载 SF2 音色库即可出声,整个应用不依赖 GTK 或 Qt。这样的架构对移植是双刃剑:没有 GUI 框架的适配负担,但事件循环、窗口表面、音频输出、文件对话框每一层都要自己接。
对鸿蒙 PC 而言,AirMidi 填的是一个真实的空档——音乐学习与演示工具,同时也是一次技术验证:Rust + wgpu 自绘应用在鸿蒙上能否走通"ArkTS 壳 + 原生渲染核心"的路线。这个路线此前在 AirModel(Blender/Vulkan)上验证过 C++ 版本,AirMidi 参考同一手册,把它推进到 Rust 生态,参照的交叉编译先例是 AirRustDesk。
当前鸿蒙工程包名 com.airmidi.app,版本 1.1.0,deviceTypes 覆盖 phone、tablet 与 2in1,方向锁定 auto_rotation_landscape(横屏设计),compressNativeLibs 关闭。Native ABI 为 arm64-v8a,核心产物是 13MB 的 libairmidi.so(Rust cdylib)。界面全中文,包括 128 个 GM 乐器名,音名与和弦符号按国际记谱法保留。上游许可证为 GPL-3.0。
▍二、先划清边界:不把编译通过当成适配完成
Neothesia 桌面版的每个平台依赖——winit、cpal、rfd、midir——在鸿蒙上都没有现成后端。适配完成的标志因此被定义为一条完整的播放链路:导入 MIDI 文件、瀑布流渲染、琴键高亮、内置合成器出声、设置持久化。任何一个环节缺失(比如只渲染不出声),对用户都不是可用的钢琴工具。工程在动手前划定的边界如下表。
层次 桌面版 Neothesia 鸿蒙侧当前方案
应用入口 main() 直接启动 winit EventLoop EntryAbility → libentry.so → dlopen libairmidi.so
窗口表面 winit 原生窗口 XComponent(SURFACE) + OHNativeWindow(rwh OhosNdk)
渲染后端 wgpu 按平台自选(Vulkan/DX/Metal/GL) wgpu 30 固定 Backends::GL(EGL 的 OHOS 支持在上游)
事件循环 winit 0.30 平台后端 winit-ohos API 兼容垫片,事件由 C ABI 注入
音频输出 cpal airmidi-ohos 手写 OHAudio FFI(dlopen libohaudio.so)
文件对话框 rfd 系统 DocumentViewPicker + TSFN 桥
MIDI 硬件输入 midir(ALSA/WinMM) midi-io cfg 垫空实现,端口列表恒空
资源路径 exe 相对 share/ 目录 AIR_DATA_DIR 环境变量指向 files/(首启从 rawfile 部署)
表里最后两行是刻意的降级:MIDI 文件播放不依赖硬件输入端口,垫空实现不影响主链路;字体方案则相反,是加强——上游默认的 Roboto 没有 CJK 字形,工程在字体系统里追加了嵌入的 Noto Sans SC 作为中文回退。
▍三、鸿蒙版的整体架构
架构是 Air 系列标准的双层结构:ArkTS 壳只负责 XComponent 宿主、系统 Picker 与资源部署;C++ 薄壳 libentry.so 通过 dlopen/dlsym 取得 Rust 核心的 C ABI;libairmidi.so 内部由三部分组成——winit-ohos 垫片、airmidi-ohos 平台层、neothesia 本体(场景、渲染、合成器与 MIDI 解析全部保留)。数据面走 rawfile 预置:default.sf2(17MB)、demo.mid 与 Noto Sans SC 在首启部署到 files/,AIR_DATA_DIR 注入后设置与音色库都从此解析。
text
EntryAbility(Stage 模型,横屏)
├── 系统文件 Picker 回调桥(TSFN)
├── 密度注入(高 DPI scale 封顶)
└── Index.ets
└── XComponent(SURFACE) → libentry.so
├── XComponent 回调:SurfaceCreated/Changed/Destroyed、触摸、滚轮、按键
├── dlopen libairmidi.so → dlsym C ABI(AirModel 同款时序)
└── libairmidi.so(Rust cdylib)
├── winit-ohos:EventLoop 全局队列 + 16ms 节拍,Window 实现 rwh 0.6
├── airmidi-ohos:OHAudio 输出 / Picker 桥 / 文件日志 / C ABI 入口
└── neothesia 本体(cfg(target_env="ohos") 分支)
├── wgpu 30 GLES/EGL 后端(零补丁)
├── oxisynth 合成器(SF2)
└── 场景 / MIDI 解析 / 瀑布流渲染
仓库主要目录如下:
text
AirMidi/
├── AppScope/app.json5 # 包名 com.airmidi.app、版本 1.1.0
├── PORTING.md # 选型、C ABI 契约、踩坑表、风险登记册
├── README.md # 功能清单、架构、里程碑
├── deploy.sh # Rust 编译 + HAP 打包 + 签名安装一键部署
├── signature/ # 签名材料与自建 CA 生成脚本
├── scripts/ # 汉化与垫片修补脚本
├── test_midi/ # 测试用 MIDI 文件
├── rust/ # Neothesia 完整源码树(适配就地携带)
│ ├── neothesia/ # 主程序(workspace 成员)
│ ├── winit-ohos/ # 新增 crate:winit 0.30 API 兼容垫片
│ ├── airmidi-ohos/ # 新增 crate:OHAudio/Picker/日志平台层
│ ├── midi-io/ # midir 替身(cfg 空实现)
│ └── build_ohos.sh # 交叉编译 → entry/libs/arm64-v8a
└── entry/
├── libs/arm64-v8a/libairmidi.so # Rust 核心产物(13MB)
└── src/main/
├── cpp/napi_init.cpp # C++ 薄壳:回调转发与 Picker TSFN 桥
├── ets/pages/Index.ets # XComponent 宿主与 picker 桥接
└── resources/rawfile/ # default.sf2、demo.mid、NotoSansSC 字体
▍四、核心适配过程
### 1. C ABI 薄壳:libentry.so 与 libairmidi.so 的契约
entry/src/main/cpp/napi_init.cpp 定义了两个动态库之间的完整契约:airmidi_start(data_dir, cache_dir, density) 启动应用线程,airmidi_set_window / airmidi_resize / airmidi_destroy_window 对应 XComponent 表面生命周期,airmidi_touch / airmidi_scroll / airmidi_key 转发输入,airmidi_pick_result 回传选择器结果,airmidi_register_picker 让 Rust 反向注册函数指针请求 ArkTS 打开文件。启动接口在设计上只能调用一次,表面销毁即退出事件循环——后台切回导致的 surface 重建走"全新启动"策略,靠 settings.ron 持久化与 last_opened_song 恢复现场。这套契约让 Rust 侧对鸿蒙的唯一感知就是一组纯 C 函数。
### 2. winit-ohos:让 winit 0.30 应用以为自己在桌面
winit-ohos 是为移植新写的 crate(包名 winit-ohos,lib 名 winit)。EventLoop 不再自取事件,而是由 C ABI 从外部注入:壳的触摸回调把事件压进全局队列,垫片按 60fps 节拍泵出,about_to_wait 到 request_redraw 再到 RedrawRequested 的语义链保持与上游一致。类型层面做了一个关键决定:keyboard 与 dpi 模块直接拷贝上游 winit 0.30.13 源码,而不是手写近似类型——垫片类型与场景代码稍有偏差就会引发编译爆炸,这是风险登记册 R2 预判过的。Window 实现了 raw-window-handle 0.6 的 OhosNdkWindowHandle,wgpu 拿到它就能创建 EGL 表面。触摸不另起炉灶:上游本就把 Touch 翻译成 CursorMoved + MouseInput,壳直接发 touch 事件走原逻辑。
### 3. wgpu GL 后端:零补丁直连
这是整个移植最大的利好。wgpu-hal 30.0.0 的 GLES/EGL 后端原生支持 OHOS(OpenHarmony 社区贡献已进上游):eglGetDisplay(DEFAULT_DISPLAY) 配合 Rwh::OhosNdk 创建窗口表面,target_env = "ohos" 分支现成。Vulkan 后端则不可用——wgpu 30 未接 OHOS surface,ash 0.38 也没有 VK_OHOS_surface——因此渲染器固定 Backends::GL,Maleoon 系列 GPU 的 GLES 3.2 对 Neothesia 的渲染需求绰绰有余。实机验证记录覆盖三种 GPU:模拟器 Mali-G77 首帧通过(软 GPU 走 adapter-limits 降级路径)、Pura X 的 Maleoon 920 与 MateBook Pro 的 Maleoon 916 渲染加触摸全链路通过。
### 4. OHAudio 输出与 oxisynth 合成器
音频层在 airmidi-ohos crate 里手写 OHAudio FFI:dlopen libohaudio.so 取得 C API,f32 立体声回调,缓冲 2×1024 帧,欠载填零,签名对齐上游 synth_backend 的需求。合成侧 oxisynth 是纯 Rust 实现,直接加载随包的 default.sf2(GM 音色)。真机验证记录里音频链路的证据是 OHAudio 回调活跃与通道事件处理日志,OS_AudioWriteCB 持续工作——合成器出声、琴键高亮与瀑布流三者在同一时间轴上对齐,播放链路才算闭合。
### 5. 文件选择器 TSFN 桥与资源部署
文件对话框的分属两侧:Rust 侧发起请求(打开 .mid、加载 .sf2、保存录音),经 oneshot future 交给 C ABI;壳侧通过 NAPI 线程安全函数(TSFN)回到 ArkTS,调 DocumentViewPicker,结果按 req_id 回传。保存链路先落 staging 文件,经 watcher 确认完整后拷出到选择器返回的 URI。资源面:rawfile 里的 default.sf2、demo.mid 与 Noto Sans SC 首启部署到 files/,resources.rs 的 ohos 分支用 AIR_DATA_DIR 环境变量替换桌面版的 exe 相对路径查找。日志同样做了平台化:文件优先写入 files/airmidi.log,只有 ERROR 级别才上 hilog,规避 hilog 流控丢日志的问题。
▍五、适配中遇到的几个难点
### 难点一:musl 上 Condvar 首次等待不醒
现象是应用线程在第一个循环迭代就卡死,Supervisor 永眠于 wait_pop_all。根因是 musl/OHOS 平台上 Condvar 首次 timed-wait 未按预期唤醒——这类平台差异不会在任何编译期暴露。解法是把全部同步原语改成 sleep-poll:1ms 粒度加 deadline 判定,彻底弃用 condvar。同族问题还有 16ms 节拍防 CPU 占满,这条经验来自 AirModel 的教训(事件循环空转会打满 CPU)。
### 难点二:模拟器软 GPU 的 device 请求失败
现象是模拟器上 request_device 失败:软 GPU 的 limits 只有 7 项 inter-stage 变量,低于 wgpu 默认需求的 15。解法是降级而非报错——Gpu::new 失败后用 adapter.limits() 的原值重试。这保证了弱 GPU 环境走降级路径而不是黑屏,真机则不受影响。风险登记册 R1 预判的"GLES 表面创建失败"最终以这种更温和的形态出现,垫片日志里保留了完整的回退链证据。
### 难点三:panic 路径把应用锁死在 AlreadyRunning
现象是核心代码某处 panic 后重启,应用立即进入 AlreadyRunning 死循环。根因是 loop_running 标志没有在 panic 路径复位。解法组合了三层:Drop guard 保证标志在任何退出路径复位、supervisor 包 catch_unwind 捕获 panic、重启加 300ms 退避。自绘应用的线程模型里,panic 恢复路径平时无人走,出问题时却直接决定应用能否自愈。
### 难点四:hvigor 打包通道与 .so 完整性
现象有两个:HAP 里恒是旧版 .so(entry/libs 变化 hvigor 不感知),以及 strip/压缩破坏动态库。解法是组合拳:打包前 touch module.json5 强制 hvigor 重打包(deploy.sh 内置此步);nativeLib.strip: false 与 compressNativeLibs: false 关掉 hvigor 的后处理,Rust 侧用 llvm-strip --strip-debug 手工精简,保留动态符号;构建脚本最后用 llvm-readelf 检查 NEEDED 只剩 libc.so。module.json5 同时声明 compressNativeLibs: false,两层配置一致。
### 难点五:高 DPI 手机上的 UI 尺度
现象是高 DPI 手机上界面元素过小:densityPixels 为 3 时逻辑宽度只有约 707px,而 UI 按桌面 1100px 设计。解法是在 set_window 时给 scale 加封顶公式 min(density, w/1100),保证逻辑宽度不低于设计值。汉化与此协同:全 UI 中文替换约 50 条硬编码英文与 128 个 GM 乐器名,音名 C 到 B 与和弦后缀按国际记谱法保留,默认字体家族仍是 Roboto、中文回退 Noto Sans SC。
▍六、编译、安装与启动
构建分两段:先交叉编译 Rust 核心,再打 HAP。前置条件是 DevEco Studio 或 CommandLineTools 的 NDK、rustup 的 aarch64-unknown-linux-ohos target;链接器配置沿用 AirRustDesk 的 .cargo/config.toml 方案(NDK clang --target=aarch64-linux-ohos 指定 sysroot)。
bash
cd rust && ./build_ohos.sh
export JAVA_HOME=/Applications/DevEco-Studio.app/Contents/jbr/Contents/Home
export DEVECO_SDK_HOME=/Applications/DevEco-Studio.app/Contents/sdk
hvigorw --mode module -p module=entry assembleHap --no-daemon
签名有一条例外规则:模拟器可用自建 CA(signature/generate_debug_sign.py),真机必须走华为云签名(devecocli auth login + devecocli signature generate),自建 CA 在真机上被 PMS 拒绝,报 9568257。安装与启动:
bash
hdc install -r entry/build/default/outputs/default/entry-default-signed.hap
hdc shell aa start -a EntryAbility -b com.airmidi.app
deploy.sh 把上述全部步骤串成一条命令(编译、打包、签名、安装、启动),按设备序列号部署。PORTING.md 收官记录(2026-08-19)确认 Pura X、MateBook Pro 渲染加触摸加音频全过。
▍七、当前功能边界
当前版本已覆盖的能力,逐条对应 README 功能清单与里程碑记录:
- MIDI 文件播放:彩色音符瀑布流 + 琴键高亮 + 内置合成器出声(GM 音色,随包 default.sf2);
- 首启自动加载演示曲 demo.mid;系统文件选择器导入 .mid、更换 .sf2 音色库;
- 轨道管理(静音/自动/人工)、琴键范围、参考线/光晕/音符标签等设置,经 settings.ron 持久化;
- 自由弹奏模式:屏上键盘弹奏 + 录音保存为 .mid(staging 到选择器 URI 的保存链路);
- 全中文 UI(含 128 个 GM 乐器名),Noto Sans SC 内嵌,高 DPI scale 封顶保证可读性;
- 三类 GPU 实机渲染验证(Mali-G77 / Maleoon 920 / Maleoon 916),横屏触摸全链路;
- 2in1 物理键盘经 native 回调可用;窗口化部署在 2in1 桌面。
尚未覆盖的能力,依据 README 已知事项与 PORTING.md 踩坑表:MIDI 硬件输入接口(midir 无 OHOS 后端,端口列表恒空,不影响文件播放);折叠屏竖持时布局为拉伸适配(内容按横屏设计,建议横屏使用);surface 被系统回收后的重建走整轮重启策略而非上下文保留。因此,当前版本更准确的定位是"Neothesia 的鸿蒙全功能播放版":看谱、听声、弹奏、录音、设置五条用户链路都已闭环,硬件 MIDI 接入与 surface 热恢复是后续增强项。
▍八、总结
AirMidi 的适配证明了一件事:Rust 自绘应用进入鸿蒙,最短路径不是把每个依赖都找到 OHOS 后端,而是在正确的层做垫片——winit-ohos 兜住事件循环与窗口语义,wgpu 的 GL 后端走上游现成的 OHOS 支持零补丁直连,音频与文件对话框这两个系统强相关层则老老实实手写 FFI。C ABI 契约把鸿蒙的感知面压缩到十一个函数,Rust 生态里数万行的场景、渲染与合成器代码因此原样运行。这份经验对 wgpu 生态的其他应用有直接的复用价值,winit-ohos 与 airmidi-ohos 两个 crate 就是为此沉淀的。
后续工作的重心按已知事项清单展开:评估 midir 之外的 MIDI 输入通道,让硬件键盘与端口设备接入弹奏链路;把 surface 热恢复从整轮重启升级为上下文保留;随更多机型铺开渲染回归。验收口径不变:以可构建的产物与真机上的实际播放行为为准,日志文件与 hilog 双通道留证。
■ 应用:AirMidi(from Neothesia)(AirMidi)
■ 定位:Neothesia 移植,MIDI 钢琴瀑布流可视化
■ 上游:Neothesia | 路线:Rust wgpu 引擎直连 | 版本:1.2.0
■ 开源: https://gitcode.com/Andy88/Harmony-Neothesia
■ 市场状态:已上架 AGC 应用页: https://appgallery.huawei.com/app/6917616744301212230
■ 官网(免费下载): https://www.shunidc.cn/
————————————————————
开源鸿蒙 PC 社区: https://harmonypc.csdn.net/
PC 社区项目申请: https://atomgit.com/OpenHarmonyPCDeveloper
适配开源地址:https://gitcode.com/Andy88/Harmony-Neothesia
环境搭建文章:https://blog.csdn.net/weixin_52908342/article/details/161343743
▍一、为什么要适配 AirMidi(Neothesia)
Neothesia 是一个开源的 MIDI 钢琴瀑布流可视化应用:MIDI 文件的每个音符以彩色长条从屏幕上方落下,到底部时对应的琴键高亮发声,是钢琴练习与演奏展示场景里辨识度很高的一类工具。上游用 Rust 编写,渲染层是 wgpu 30 自绘,合成器是纯 Rust 的 oxisynth,加载 SF2 音色库即可出声,整个应用不依赖 GTK 或 Qt。这样的架构对移植是双刃剑:没有 GUI 框架的适配负担,但事件循环、窗口表面、音频输出、文件对话框每一层都要自己接。
对鸿蒙 PC 而言,AirMidi 填的是一个真实的空档——音乐学习与演示工具,同时也是一次技术验证:Rust + wgpu 自绘应用在鸿蒙上能否走通"ArkTS 壳 + 原生渲染核心"的路线。这个路线此前在 AirModel(Blender/Vulkan)上验证过 C++ 版本,AirMidi 参考同一手册,把它推进到 Rust 生态,参照的交叉编译先例是 AirRustDesk。
当前鸿蒙工程包名 com.airmidi.app,版本 1.1.0,deviceTypes 覆盖 phone、tablet 与 2in1,方向锁定 auto_rotation_landscape(横屏设计),compressNativeLibs 关闭。Native ABI 为 arm64-v8a,核心产物是 13MB 的 libairmidi.so(Rust cdylib)。界面全中文,包括 128 个 GM 乐器名,音名与和弦符号按国际记谱法保留。上游许可证为 GPL-3.0。
▍二、先划清边界:不把编译通过当成适配完成
Neothesia 桌面版的每个平台依赖——winit、cpal、rfd、midir——在鸿蒙上都没有现成后端。适配完成的标志因此被定义为一条完整的播放链路:导入 MIDI 文件、瀑布流渲染、琴键高亮、内置合成器出声、设置持久化。任何一个环节缺失(比如只渲染不出声),对用户都不是可用的钢琴工具。工程在动手前划定的边界如下表。
层次 桌面版 Neothesia 鸿蒙侧当前方案
应用入口 main() 直接启动 winit EventLoop EntryAbility → libentry.so → dlopen libairmidi.so
窗口表面 winit 原生窗口 XComponent(SURFACE) + OHNativeWindow(rwh OhosNdk)
渲染后端 wgpu 按平台自选(Vulkan/DX/Metal/GL) wgpu 30 固定 Backends::GL(EGL 的 OHOS 支持在上游)
事件循环 winit 0.30 平台后端 winit-ohos API 兼容垫片,事件由 C ABI 注入
音频输出 cpal airmidi-ohos 手写 OHAudio FFI(dlopen libohaudio.so)
文件对话框 rfd 系统 DocumentViewPicker + TSFN 桥
MIDI 硬件输入 midir(ALSA/WinMM) midi-io cfg 垫空实现,端口列表恒空
资源路径 exe 相对 share/ 目录 AIR_DATA_DIR 环境变量指向 files/(首启从 rawfile 部署)
表里最后两行是刻意的降级:MIDI 文件播放不依赖硬件输入端口,垫空实现不影响主链路;字体方案则相反,是加强——上游默认的 Roboto 没有 CJK 字形,工程在字体系统里追加了嵌入的 Noto Sans SC 作为中文回退。
▍三、鸿蒙版的整体架构
架构是 Air 系列标准的双层结构:ArkTS 壳只负责 XComponent 宿主、系统 Picker 与资源部署;C++ 薄壳 libentry.so 通过 dlopen/dlsym 取得 Rust 核心的 C ABI;libairmidi.so 内部由三部分组成——winit-ohos 垫片、airmidi-ohos 平台层、neothesia 本体(场景、渲染、合成器与 MIDI 解析全部保留)。数据面走 rawfile 预置:default.sf2(17MB)、demo.mid 与 Noto Sans SC 在首启部署到 files/,AIR_DATA_DIR 注入后设置与音色库都从此解析。
text
EntryAbility(Stage 模型,横屏)
├── 系统文件 Picker 回调桥(TSFN)
├── 密度注入(高 DPI scale 封顶)
└── Index.ets
└── XComponent(SURFACE) → libentry.so
├── XComponent 回调:SurfaceCreated/Changed/Destroyed、触摸、滚轮、按键
├── dlopen libairmidi.so → dlsym C ABI(AirModel 同款时序)
└── libairmidi.so(Rust cdylib)
├── winit-ohos:EventLoop 全局队列 + 16ms 节拍,Window 实现 rwh 0.6
├── airmidi-ohos:OHAudio 输出 / Picker 桥 / 文件日志 / C ABI 入口
└── neothesia 本体(cfg(target_env="ohos") 分支)
├── wgpu 30 GLES/EGL 后端(零补丁)
├── oxisynth 合成器(SF2)
└── 场景 / MIDI 解析 / 瀑布流渲染
仓库主要目录如下:
text
AirMidi/
├── AppScope/app.json5 # 包名 com.airmidi.app、版本 1.1.0
├── PORTING.md # 选型、C ABI 契约、踩坑表、风险登记册
├── README.md # 功能清单、架构、里程碑
├── deploy.sh # Rust 编译 + HAP 打包 + 签名安装一键部署
├── signature/ # 签名材料与自建 CA 生成脚本
├── scripts/ # 汉化与垫片修补脚本
├── test_midi/ # 测试用 MIDI 文件
├── rust/ # Neothesia 完整源码树(适配就地携带)
│ ├── neothesia/ # 主程序(workspace 成员)
│ ├── winit-ohos/ # 新增 crate:winit 0.30 API 兼容垫片
│ ├── airmidi-ohos/ # 新增 crate:OHAudio/Picker/日志平台层
│ ├── midi-io/ # midir 替身(cfg 空实现)
│ └── build_ohos.sh # 交叉编译 → entry/libs/arm64-v8a
└── entry/
├── libs/arm64-v8a/libairmidi.so # Rust 核心产物(13MB)
└── src/main/
├── cpp/napi_init.cpp # C++ 薄壳:回调转发与 Picker TSFN 桥
├── ets/pages/Index.ets # XComponent 宿主与 picker 桥接
└── resources/rawfile/ # default.sf2、demo.mid、NotoSansSC 字体
▍四、核心适配过程
### 1. C ABI 薄壳:libentry.so 与 libairmidi.so 的契约
entry/src/main/cpp/napi_init.cpp 定义了两个动态库之间的完整契约:airmidi_start(data_dir, cache_dir, density) 启动应用线程,airmidi_set_window / airmidi_resize / airmidi_destroy_window 对应 XComponent 表面生命周期,airmidi_touch / airmidi_scroll / airmidi_key 转发输入,airmidi_pick_result 回传选择器结果,airmidi_register_picker 让 Rust 反向注册函数指针请求 ArkTS 打开文件。启动接口在设计上只能调用一次,表面销毁即退出事件循环——后台切回导致的 surface 重建走"全新启动"策略,靠 settings.ron 持久化与 last_opened_song 恢复现场。这套契约让 Rust 侧对鸿蒙的唯一感知就是一组纯 C 函数。
### 2. winit-ohos:让 winit 0.30 应用以为自己在桌面
winit-ohos 是为移植新写的 crate(包名 winit-ohos,lib 名 winit)。EventLoop 不再自取事件,而是由 C ABI 从外部注入:壳的触摸回调把事件压进全局队列,垫片按 60fps 节拍泵出,about_to_wait 到 request_redraw 再到 RedrawRequested 的语义链保持与上游一致。类型层面做了一个关键决定:keyboard 与 dpi 模块直接拷贝上游 winit 0.30.13 源码,而不是手写近似类型——垫片类型与场景代码稍有偏差就会引发编译爆炸,这是风险登记册 R2 预判过的。Window 实现了 raw-window-handle 0.6 的 OhosNdkWindowHandle,wgpu 拿到它就能创建 EGL 表面。触摸不另起炉灶:上游本就把 Touch 翻译成 CursorMoved + MouseInput,壳直接发 touch 事件走原逻辑。
### 3. wgpu GL 后端:零补丁直连
这是整个移植最大的利好。wgpu-hal 30.0.0 的 GLES/EGL 后端原生支持 OHOS(OpenHarmony 社区贡献已进上游):eglGetDisplay(DEFAULT_DISPLAY) 配合 Rwh::OhosNdk 创建窗口表面,target_env = "ohos" 分支现成。Vulkan 后端则不可用——wgpu 30 未接 OHOS surface,ash 0.38 也没有 VK_OHOS_surface——因此渲染器固定 Backends::GL,Maleoon 系列 GPU 的 GLES 3.2 对 Neothesia 的渲染需求绰绰有余。实机验证记录覆盖三种 GPU:模拟器 Mali-G77 首帧通过(软 GPU 走 adapter-limits 降级路径)、Pura X 的 Maleoon 920 与 MateBook Pro 的 Maleoon 916 渲染加触摸全链路通过。
### 4. OHAudio 输出与 oxisynth 合成器
音频层在 airmidi-ohos crate 里手写 OHAudio FFI:dlopen libohaudio.so 取得 C API,f32 立体声回调,缓冲 2×1024 帧,欠载填零,签名对齐上游 synth_backend 的需求。合成侧 oxisynth 是纯 Rust 实现,直接加载随包的 default.sf2(GM 音色)。真机验证记录里音频链路的证据是 OHAudio 回调活跃与通道事件处理日志,OS_AudioWriteCB 持续工作——合成器出声、琴键高亮与瀑布流三者在同一时间轴上对齐,播放链路才算闭合。
### 5. 文件选择器 TSFN 桥与资源部署
文件对话框的分属两侧:Rust 侧发起请求(打开 .mid、加载 .sf2、保存录音),经 oneshot future 交给 C ABI;壳侧通过 NAPI 线程安全函数(TSFN)回到 ArkTS,调 DocumentViewPicker,结果按 req_id 回传。保存链路先落 staging 文件,经 watcher 确认完整后拷出到选择器返回的 URI。资源面:rawfile 里的 default.sf2、demo.mid 与 Noto Sans SC 首启部署到 files/,resources.rs 的 ohos 分支用 AIR_DATA_DIR 环境变量替换桌面版的 exe 相对路径查找。日志同样做了平台化:文件优先写入 files/airmidi.log,只有 ERROR 级别才上 hilog,规避 hilog 流控丢日志的问题。
▍五、适配中遇到的几个难点
### 难点一:musl 上 Condvar 首次等待不醒
现象是应用线程在第一个循环迭代就卡死,Supervisor 永眠于 wait_pop_all。根因是 musl/OHOS 平台上 Condvar 首次 timed-wait 未按预期唤醒——这类平台差异不会在任何编译期暴露。解法是把全部同步原语改成 sleep-poll:1ms 粒度加 deadline 判定,彻底弃用 condvar。同族问题还有 16ms 节拍防 CPU 占满,这条经验来自 AirModel 的教训(事件循环空转会打满 CPU)。
### 难点二:模拟器软 GPU 的 device 请求失败
现象是模拟器上 request_device 失败:软 GPU 的 limits 只有 7 项 inter-stage 变量,低于 wgpu 默认需求的 15。解法是降级而非报错——Gpu::new 失败后用 adapter.limits() 的原值重试。这保证了弱 GPU 环境走降级路径而不是黑屏,真机则不受影响。风险登记册 R1 预判的"GLES 表面创建失败"最终以这种更温和的形态出现,垫片日志里保留了完整的回退链证据。
### 难点三:panic 路径把应用锁死在 AlreadyRunning
现象是核心代码某处 panic 后重启,应用立即进入 AlreadyRunning 死循环。根因是 loop_running 标志没有在 panic 路径复位。解法组合了三层:Drop guard 保证标志在任何退出路径复位、supervisor 包 catch_unwind 捕获 panic、重启加 300ms 退避。自绘应用的线程模型里,panic 恢复路径平时无人走,出问题时却直接决定应用能否自愈。
### 难点四:hvigor 打包通道与 .so 完整性
现象有两个:HAP 里恒是旧版 .so(entry/libs 变化 hvigor 不感知),以及 strip/压缩破坏动态库。解法是组合拳:打包前 touch module.json5 强制 hvigor 重打包(deploy.sh 内置此步);nativeLib.strip: false 与 compressNativeLibs: false 关掉 hvigor 的后处理,Rust 侧用 llvm-strip --strip-debug 手工精简,保留动态符号;构建脚本最后用 llvm-readelf 检查 NEEDED 只剩 libc.so。module.json5 同时声明 compressNativeLibs: false,两层配置一致。
### 难点五:高 DPI 手机上的 UI 尺度
现象是高 DPI 手机上界面元素过小:densityPixels 为 3 时逻辑宽度只有约 707px,而 UI 按桌面 1100px 设计。解法是在 set_window 时给 scale 加封顶公式 min(density, w/1100),保证逻辑宽度不低于设计值。汉化与此协同:全 UI 中文替换约 50 条硬编码英文与 128 个 GM 乐器名,音名 C 到 B 与和弦后缀按国际记谱法保留,默认字体家族仍是 Roboto、中文回退 Noto Sans SC。
▍六、编译、安装与启动
构建分两段:先交叉编译 Rust 核心,再打 HAP。前置条件是 DevEco Studio 或 CommandLineTools 的 NDK、rustup 的 aarch64-unknown-linux-ohos target;链接器配置沿用 AirRustDesk 的 .cargo/config.toml 方案(NDK clang --target=aarch64-linux-ohos 指定 sysroot)。
bash
cd rust && ./build_ohos.sh
export JAVA_HOME=/Applications/DevEco-Studio.app/Contents/jbr/Contents/Home
export DEVECO_SDK_HOME=/Applications/DevEco-Studio.app/Contents/sdk
hvigorw --mode module -p module=entry assembleHap --no-daemon
签名有一条例外规则:模拟器可用自建 CA(signature/generate_debug_sign.py),真机必须走华为云签名(devecocli auth login + devecocli signature generate),自建 CA 在真机上被 PMS 拒绝,报 9568257。安装与启动:
bash
hdc install -r entry/build/default/outputs/default/entry-default-signed.hap
hdc shell aa start -a EntryAbility -b com.airmidi.app
deploy.sh 把上述全部步骤串成一条命令(编译、打包、签名、安装、启动),按设备序列号部署。PORTING.md 收官记录(2026-08-19)确认 Pura X、MateBook Pro 渲染加触摸加音频全过。
▍七、当前功能边界
当前版本已覆盖的能力,逐条对应 README 功能清单与里程碑记录:
- MIDI 文件播放:彩色音符瀑布流 + 琴键高亮 + 内置合成器出声(GM 音色,随包 default.sf2);
- 首启自动加载演示曲 demo.mid;系统文件选择器导入 .mid、更换 .sf2 音色库;
- 轨道管理(静音/自动/人工)、琴键范围、参考线/光晕/音符标签等设置,经 settings.ron 持久化;
- 自由弹奏模式:屏上键盘弹奏 + 录音保存为 .mid(staging 到选择器 URI 的保存链路);
- 全中文 UI(含 128 个 GM 乐器名),Noto Sans SC 内嵌,高 DPI scale 封顶保证可读性;
- 三类 GPU 实机渲染验证(Mali-G77 / Maleoon 920 / Maleoon 916),横屏触摸全链路;
- 2in1 物理键盘经 native 回调可用;窗口化部署在 2in1 桌面。
尚未覆盖的能力,依据 README 已知事项与 PORTING.md 踩坑表:MIDI 硬件输入接口(midir 无 OHOS 后端,端口列表恒空,不影响文件播放);折叠屏竖持时布局为拉伸适配(内容按横屏设计,建议横屏使用);surface 被系统回收后的重建走整轮重启策略而非上下文保留。因此,当前版本更准确的定位是"Neothesia 的鸿蒙全功能播放版":看谱、听声、弹奏、录音、设置五条用户链路都已闭环,硬件 MIDI 接入与 surface 热恢复是后续增强项。
▍八、总结
AirMidi 的适配证明了一件事:Rust 自绘应用进入鸿蒙,最短路径不是把每个依赖都找到 OHOS 后端,而是在正确的层做垫片——winit-ohos 兜住事件循环与窗口语义,wgpu 的 GL 后端走上游现成的 OHOS 支持零补丁直连,音频与文件对话框这两个系统强相关层则老老实实手写 FFI。C ABI 契约把鸿蒙的感知面压缩到十一个函数,Rust 生态里数万行的场景、渲染与合成器代码因此原样运行。这份经验对 wgpu 生态的其他应用有直接的复用价值,winit-ohos 与 airmidi-ohos 两个 crate 就是为此沉淀的。
后续工作的重心按已知事项清单展开:评估 midir 之外的 MIDI 输入通道,让硬件键盘与端口设备接入弹奏链路;把 surface 热恢复从整轮重启升级为上下文保留;随更多机型铺开渲染回归。验收口径不变:以可构建的产物与真机上的实际播放行为为准,日志文件与 hilog 双通道留证。
楼主
8 天前