把 FSearch 搬上鸿蒙 PC:C 查询内核移植 + ArkTS 原生重写“秒搜“即时文件搜索全记录
欢迎加入开源鸿蒙 PC 社区:https://harmonypc.csdn.net/
欢迎在 PC 社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper
适配开源地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_Fsearch
本文记录把开源即时文件搜索工具 FSearch(Linux 世界里的 “Everything”)适配到 HarmonyOS PC 的完整过程。这次移植走了一条"ArkTS 打底 + C 内核真移植"的混合路线:上游的查询语法、过滤器规格、数据分层被完整继承;查询内核(lexer → shunting-yard 解析 → 表达式树 → 匹配器)用纯 C 重建,并链接交叉编译的 PCRE2 10.44 静态库,经 NAPI 进程内调用;GTK3 界面被逐元素复刻成 ArkUI 的 headerbar 风格;文件访问走零系统权限的 picker 用户授权模型。全文约万字,包含源码侦察结论、六处真刀真枪的踩坑定位、七个镜头的真机验收,以及一章完整的 QA。

图 1:秒搜 FSearch 运行在鸿蒙 PC 桌面上,空状态引导页 + 即时搜索输入框

一、缘起:鸿蒙 PC 缺一个"Everything"
用过 Windows 上 Everything 的人,很难再忍受任何"点搜索按钮等进度条"的文件查找方式:索引常驻内存、输入即出结果、通配符正则随便组合、几百万条目毫秒级响应。Linux 阵营里对应这个定位的开源项目是 FSearch——由 Christian Boxdörfer 维护,C 语言 + GTK3,GitHub 上千星,发行版仓库(Ubuntu PPA / Arch / Fedora COPR / Flathub)全线覆盖,README 里一句话说清了它的野心:
Performance. On Windows I really like to use Everything Search Engine… On Linux I couldn’t find anything that’s even remotely as fast and powerful.
鸿蒙 PC 的桌面生态正在快速补齐办公、IDE、设计工具,但"文件检索/效率工具"这个垂类还是空白。系统自带的文件管理器有全局搜索,但它是"现场遍历"式搜索——目录一大,等待就来了。把 FSearch 的核心体验(索引常驻 + 输入即出结果 + 高级查询语法)带上鸿蒙 PC,就是这个项目的全部动机。
适配目标同样先说清楚:在鸿蒙 PC 上复刻 FSearch 的核心工作流与查询体验,而不是把 GTK3 编译进 HAP——后者在第四章会展开讲为什么不可行。

二、认识上游:FSearch 的代码是怎么分层的
动手之前先把上游源码克隆下来通读了一遍(github.com/cboxdoerfer/fsearch,master 分支 0.3 版本,GPL-2.0-or-later)。这一步收获远超预期——上游的分层比我预想的干净得多。
构建系统是 Meson,src/meson.build 里明确拆了两个静态库:
| 层 | 文件数 | 内容 | 对 GTK 的依赖 |
|---|---|---|---|
libfsearch_core | 36 个 .c | 数据库索引、扫描、查询解析匹配、过滤器、文件监控、持久化 | 零(12 个单测只链接 core,编译级证明) |
libfsearch | 15 个 .c + 9 个 .ui | GTK3 窗口、自绘列表(fsearch_list_view.c,86KB)、首选项、D-Bus 单实例 | 全部 |
core 的真实依赖链也摸清了:GLib/GIO(主循环、GObject 信号、线程池)、PCRE2(正则 + JIT)、ICU(大小写折叠与归一化)、Pango(仅高亮数据结构)。还有几个值得记下的细节:
- 索引是自定义二进制格式(
fsearch_database_file.c):FSDBmagic + MD5 校验 + 名字前缀压缩 + 排序索引数组,保存用.tmp+flock+rename原子替换; - 扫描用 Linux 特有 API:
opendir + dirfd + fstatat(AT_SYMLINK_NOFOLLOW|AT_NO_AUTOMOUNT),跨文件系统用st_dev判断; - 实时监控完全自建:
fsearch_folder_monitor_inotify.c/_fanotify.c,Meson 构建期能力探测生成HAVE_INOTIFY/HAVE_FANOTIFY宏,不支持就编译期裁掉; - 异步模型压在 GLib 上:
FsearchDatabase一个类挂了 19 个 GObject 信号(load-started / scan-finished / search-* …),UI 状态机全靠它们驱动; - 查询语法规格在
help/C/search_syntax_*.page里,是权威文档:布尔 AND/OR/NOT、隐式 AND、ext:<分号分隔列表>、size:/dm:数值日期区间、宏过滤器、regex 修饰符。
这份侦察结论直接决定了后面的架构映射:core 的"职责划分"可以整体继承,core 的"技术底座"必须整体替换。
三、可行性侦察:三堵墙与路线决策
把上游技术栈和鸿蒙 PC 的约束对撞,撞出来三堵墙:
墙一:没有 GTK 运行时。 鸿蒙 PC 的 GUI 承载路线是 Electron / Qt-for-OHOS / Flutter / RNOH / ArkTS 原生,社区没有 GTK3-on-OHOS 底座。为 FSearch 单独移植一套 GTK3 + GVfs + D-Bus 栈,工程量比应用本身还大。上游 UI 整体承载这条路直接判死。
墙二:沙箱文件可见性。 FSearch 的灵魂是"全盘索引",而 HarmonyOS 应用沙箱默认只能访问自己的 filesDir,用户文件必须经 DocumentViewPicker 用户授权。这个差异是产品级的:移植版的索引范围 = 用户主动授权的目录,而不是整块磁盘。
墙三:无 inotify/fanotify。 沙箱应用拿不到内核文件监控能力,上游的实时增量索引方案无法照搬,必须换手动/定时重扫。
摆在这面前的路线有五条(Electron 整体承载、Web+本地服务、Qt 交叉编译、Flutter、纯原生重写),前四条要么和 GTK 无关,要么重得没有意义。真正的决策在两个选项之间:
- 路线 B’:保留 C core,交叉编译 GLib/GIO/ICU/PCRE2 全家桶 + NAPI 壳 + ArkUI 重写 UI。可行,但最重:GLib 主循环和 19 个信号要重新接到 NAPI 线程模型上,"绝对路径主键"的索引模型和沙箱 URI 授权模型冲突,回归风险极高;
- 路线 C:ArkTS 原生重写。数据结构与算法逻辑参照 core(动态数组、排序索引、词法/语法解析、过滤器表)以 ArkTS 重建,重活放 Worker 线程,UI 用 ArkUI 复刻。
最终走的是两阶段混合路线:
- Phase 1(ArkTS 打底):数据结构与算法逻辑参照 core 以 ArkTS 重建(动态数组、排序索引、词法/语法解析、过滤器表),重活放 Worker 线程,UI 用 ArkUI 复刻——先用最小代价跑通主闭环,把不确定性收敛到"文件可见性"这一个点上;
- Phase 2(C 内核真移植):把上游查询内核的算法(lexer → shunting-yard parser → 表达式树 → matcher)剥离 GLib/ICU/Pango 后用纯 C 重建,链接交叉编译的 PCRE2 10.44 静态库,经 NAPI 进程内调用——这一步真刀真枪做完了,详见 4.3 与 4.6。
回头看,Phase 1 的 11 条目录毫秒级数据(见第六章性能表)证明 ArkTS 打底没有白做;而 Phase 2 让"移植"二字名副其实——正则匹配跑的不再是 JS RegExp,而是与上游同一款的 PCRE2。
四、工程落地:分层架构与关键实现
4.1 工程结构与消息协议
新工程 ohos_Fsearch(bundle org.fsearch.ohos,SDK 6.0.1(21),deviceTypes 2in1/tablet),ArkTS 五模块 + C 内核,一一对应上游分层:
entry/src/main/
├─ ets/
│ ├─ pages/Index.ets # UI 层(对齐 libfsearch:headerbar/列表/菜单)
│ ├─ workers/IndexWorker.ets # 扫描与消息路由(对齐 core:database_scan/work)
│ ├─ query/Filters.ets # 过滤器表(对齐 core:fsearch_filter.c 默认表)
│ ├─ indexer/IndexerClient.ets # 主线程 Worker 封装(对齐 core:信号→回调)
│ ├─ store/ConfigStore.ets # 配置持久化(对齐 core:GKeyFile→JSON 落沙箱)
│ └─ model/IndexTypes.ets # 数据模型与 Worker 消息协议
└─ cpp/
├─ fsearch_query_core.c/h # C 查询内核(对齐 core:lexer/parser/tree/matchers)
├─ fsearch_napi.cpp # NAPI 桥(索引注入 C 侧 + 搜索/高亮出口)
├─ cmake/ohos-aarch64.cmake # PCRE2 交叉编译工具链
├─ CMakeLists.txt # 链接交叉编译的 libpcre2-8.a
└─ third_party/pcre2/ # PCRE2 10.44 头文件 + aarch64 静态库
(Phase 1 的 ArkTS 版查询引擎 QueryEngine.ets 仍保留在仓库中,作为原型与行为对照,已不再被引用。)
跨线程只有一条消息通道,协议显式定义成接口(ArkTS 严格模式不允许裸对象):主线程 → Worker 发 scan / search / removeRoot,Worker 回 scan_start / scan_progress / scan_done / search_result / root_stats。上游的 19 个 GObject 信号被收敛成 7 个回调字段——UI 状态机需要的恰好就这么多。
4.2 扫描内核:显式栈 + listFileSync
上游用递归 + fstatat,鸿蒙侧用 @kit.CoreFileKit 的 fs.listFileSync / fs.statSync。有两个刻意的设计:
迭代替递归——深目录树会把调用栈打爆,改用显式栈:
// IndexWorker.ets(节选)
const stack: ScanTask[] = [{ path: rootPath, depth: 0 }];
while (stack.length > 0) {
if (entries.length >= maxEntries) { /* 20 万条上限熔断 */ break; }
const task = stack.pop();
let names: string[] = [];
try { names = fs.listFileSync(task.path); }
catch (e) { errors.push('listFile 失败: ' + task.path); continue; }
for (const name of names) {
const st = fs.statSync(full); // isDirectory/size/mtime
entries.push(toEntry(rootId, full, name, st.isDirectory(), st.size, st.mtime));
if (st.isDirectory()) stack.push({ path: full, depth: task.depth + 1 });
}
}
双上限熔断——条目 200000、深度 14,防止误选超大目录把 Worker 钉死;每 2000 条回一次 scan_progress,UI 状态栏实时跳动。
扫描产出的条目(含预折叠 nameLower/pathLower 与扩展名分类)经 NAPI 一次性注入 C 侧常驻索引,此后的搜索、移除目录全部在 C 内核里完成,主线程只收结果——这是"即时搜索不卡 UI"的结构性保证。
4.3 查询内核:从 ArkTS 原型到 C/PCRE2 移植
查询语法(当前范围):空格分词(隐式 AND)、双引号短语、*/? 通配符、括号组合、AND/OR/NOT(&&/||/!)、ext:/file:/folder:/path: 字段前缀、regex: 正则。这个引擎经历了两个版本:**ArkTS 原型版(Phase 1)**验证了语法设计与交互,正则走 JS RegExp;**C 内核版(Phase 2,现行)**把整条链路搬进 C,正则换成与上游同一款的 PCRE2。
上游有个聪明的实现细节被两个版本共同继承:通配符不写独立匹配器,而是转成锚定正则(上游注释明说这比 fnmatch 快且对 UTF-8 更友好)——C 版:
/* fsearch_query_core.c(节选)*/
static char* fqc_wildcard_to_regex(const char* w) {
char* out = malloc(strlen(w) * 2 + 8);
size_t o = 0;
out[o++] = '^';
for (size_t i = 0; i < n; i++) {
if (w[i] == '*') { out[o++] = '.'; out[o++] = '*'; }
else if (w[i] == '?') { out[o++] = '.'; }
else if (strchr(".+?^${}()|[]\\/", w[i])) { out[o++] = '\\'; out[o++] = w[i]; }
else { out[o++] = w[i]; }
}
out[o++] = '$';
return out;
}
解析器按上游同款 shunting-yard(调度场)算法实现:词法产出 WORD/AND/OR/NOT/括号流,运算符按 NOT(3) > AND(2) > OR(1) 优先级弹栈建树,相邻操作数之间插入隐式 AND——对应上游 get_implicit_and_if_necessary 的语义。正则节点编译时带 PCRE2_CASELESS | PCRE2_UTF:大小写折叠由 PCRE2 的 Unicode 字符数据库(UCD)完成,语义对齐上游 ICU 的 ucasemap(比 ASCII tolower 覆盖广得多)。ext: 按上游语法支持分号多值(ext:jpg;png;gif)。
高亮也做进了内核:命中时对名称收集 [start,end) 字节区间——子串走 strstr、正则走 pcre2_match 的 ovector——随结果一起回传,ArkUI 侧只需把区间切成 Span 上色。它对应上游 Pango 高亮属性的角色,但数据结构干净得多,UI 层零计算。
4.4 过滤器:逐字对齐上游默认表
上游 fsearch_filter_get_default_filters() 的 9 个默认过滤器本质是"查询串前缀宏",这份表被原样翻译成 ArkTS(扩展名列表逐字抄自上游源码,如文档类 60+ 扩展名、视频类 70+ 扩展名):
// Filters.ets(节选)
export const DEFAULT_FILTERS: SearchFilter[] = [
{ name: '全部', query: '' },
{ name: '文件夹', query: 'folder:' },
{ name: '文件', query: 'file:' },
{ name: '应用程序', query: 'ext:exe;msi;bat;cmd;com;sh;desktop;app;hap;appimage' },
{ name: '文档', query: 'ext:c;chm;cpp;csv;doc;docx;pdf;ppt;xls;xlsx;txt;md;...' },
{ name: '音频', query: 'ext:aac;flac;m4a;mp3;ogg;opus;wav;wma;...' },
{ name: '图片', query: 'ext:avif;bmp;gif;heic;ico;jpeg;jpg;png;psd;svg;webp;...' },
{ name: '视频', query: 'ext:3gp;avi;flv;m4v;mkv;mov;mp4;mpeg;mpg;webm;wmv;...' },
{ name: '压缩包', query: 'ext:7z;bz2;cab;gz;jar;rar;tar;tgz;xz;z;zip;...' }
];
过滤器与用户输入的组合方式:effectiveQuery = filter.query + ' ' + userInput,走同一个解析器——和上游"过滤器即宏"的设计完全同构。
4.5 UI:把 GTK headerbar 复刻成 ArkUI
界面对标上游 headerbar 版式的三段结构:左汉堡菜单 + 居中搜索胶囊 + 右过滤器下拉;下方是可排序列头(名称/路径/大小/修改时间,点列头切换升降序)、多列结果列表、底部状态栏。12 枚类型图标(目录/文件/文档/图片/音频/视频/压缩包/应用 + 搜索/菜单/漏斗/刷新)全部手写 SVG,按文件扩展名分类着色——目录蓝、图片绿、视频红、音频紫、压缩包棕,视觉语义对齐 GNOME 文件管理器的习惯。行交互:单击请求系统打开,右键弹上下文菜单(打开 / 打开所在文件夹 / 复制路径 / 复制文件名)。
空状态也不将就:大号放大镜 + “输入以即时搜索” + 语法速记 + 无索引时的"添加索引目录"主按钮——这是用户对应用的第一眼,值得花心思。
4.6 C 内核上线:PCRE2 交叉编译 + NAPI 桥
PCRE2 交叉编译:DevEco SDK 自带 LLVM(clang/llvm-ar)与 sysroot,再配一个 30 行的工具链文件,就能把 PCRE2 10.44 编成 aarch64 静态库:
# cpp/cmake/ohos-aarch64.cmake(节选)
set(CMAKE_C_COMPILER "${OHOS_NATIVE_ROOT}/llvm/bin/clang.exe")
set(CMAKE_C_COMPILER_TARGET aarch64-linux-ohos)
set(CMAKE_SYSROOT "${OHOS_NATIVE_ROOT}/sysroot")
cmake -S pcre2 -B build-ohos -G Ninja \
-DCMAKE_TOOLCHAIN_FILE=ohos-aarch64.cmake \
-DPCRE2_SUPPORT_JIT=ON -DPCRE2_SUPPORT_UNICODE=ON \
-DBUILD_SHARED_LIBS=OFF
cmake --build build-ohos --target pcre2-8-static
产物 libpcre2-8.a(846KB)连同 configure 生成的 pcre2.h 收编进工程 cpp/third_party/pcre2/,工程自包含,不依赖编译现场。
NAPI 桥五个接口:addEntries(扫描结果一次性注入 C 侧常驻索引)、removeRoot、search(回传命中行 + 高亮区间 + 总数 + 耗时 + 引擎标识)、stats、clear。线程模型刻意简单:所有接口由 Worker 线程同步调用,C 侧无锁。
JIT 的现实:PCRE2 的 JIT 需要可执行内存映射,而应用沙箱不一定允许。内核的处理与上游容错语义一致——pcre2_jit_compile 成功走 JIT,失败自动回退解释匹配,两种路径结果完全一致,界面状态栏如实显示当前引擎(C/PCRE2·JIT 或 C/PCRE2)。
五、那些真刀真枪的踩坑
5.1 folder picker API 的"失踪案"
第一个坑来自最核心的功能点:选文件夹。文档里直觉地写 new picker.DocumentFolderSelectOptions(),编译器当场打脸:
Property 'DocumentFolderSelectOptions' does not exist on type 'typeof picker'.
Did you mean 'DocumentSelectOptions'?
翻 SDK 的 @ohos.file.picker.d.ts 取证,文件夹选择其实藏在 DocumentSelectOptions 的一个字段里:
const opt = new picker.DocumentSelectOptions();
opt.selectMode = picker.DocumentSelectMode.FOLDER; // FILE=0 / FOLDER=1 / MIXED=2
opt.maxSelectNumber = 1;
DocumentSelectMode 自 API 11 就有,且带 FolderSelection syscap(2in1 支持)。教训:API 名称猜不准时,直接读 SDK 的 d.ts,比搜二手资料可靠。
5.2 FILE_ACCESS_PERSIST:一个 ACL 弹窗引发的权限瘦身
为了"重启后授权目录仍可读",最初申请了 ohos.permission.FILE_ACCESS_PERSIST,配合 fileShare.persistPermission() 做授权持久化。DevEco 打包时弹出了受限 ACL 权限确认框——这类权限发布上架时需要单独提交审核。
权衡后的决定是砍掉:读 d.ts 确认 persistPermission(policies): Promise<void> 仅此权限一个用途;而"重启后重新授权一次"对搜索工具的体验损伤可控(索引配置仍在沙箱里,重扫一下就恢复)。最终 module.json5 回归 requestPermissions: []——零系统权限、零 ACL,上架审核面最小化,隐私口径也最干净:应用能碰的文件,永远只是用户刚刚亲手授权的那一个目录。
5.3 pnpm 把 file:// 解析成 C:\C:\:hvigor 缓存惊魂
新工程首次 CLI 构建,hvigor 直接报 00308003 ENOENT .../node_modules/@ohos/hvigor/bin/hvigor.js,按报错删缓存重试依然复现。追进去发现缓存 workspace 里两个依赖被 pnpm 装成了 junction(目录联接),而 junction 指向竟是一个不存在的 C:\C:\Program Files\...——hvigor-config.json5 里的 file://C:/Program Files/... 被解析坏了。
根治三步:用 .NET 的 Directory.Delete 删掉 junction 本身(不穿透坏目标);把 DevEco 自带的 hvigor 与 hvigor-ohos-plugin 两个自包含包物理复制进缓存 workspace 的 node_modules/@ohos/;再把 workspace 的 package.json 依赖重写回 file://C:/ 形式让 wrapper 跳过 pnpm install。之后构建一路绿灯。这类"工具链缓存损坏"的报错信息极具迷惑性,看到 ENOENT 指向 hvigor 自身,先查 junction 指向再动手。
5.4 ArkTS 严格模式三连击
ArkTS(arkts-* 规则)对从 TS 过来的人有一串暗坑,本工程踩了三个典型的:
- 对象字面量实现 interface 回调(
const cb: IndexerCallbacks = {...})触发arkts-no-untyped-obj-literals——重构为IndexerClient上的函数字段(client.cbScanDone = (...) => {...}),逐个赋值,语义不变; - MenuItem 的文本字段是
content不是label——本 SDK 的MenuItemOptions里没有 label 字段,9 处菜单项全部改MenuItem({ content: '...' }); - 消息类型转换:Worker 消息统一
const base = e.data as WorkerMsgBase后按type分发再窄化,不做Record<string,Object>的双重 as(编译器会拒绝不重叠类型的直接转换)。
5.5 9568320 “no signature file”:悬空的签名引用
DevEco 自动签名后直接 Run,报 code:9568320 error: no signature file,且部署日志里推的是 entry-default-unsigned.hap。检查发现签名材料其实已经生成(.ohos/config/default_ohos_Fsearch_*.p12/.cer/.p7b 三件套齐全,signingConfigs 也写入了),但 products 节点缺了 "signingConfig": "default" 引用——签名配置悬空,构建时静默跳过签名。补上一行引用,重新 Run 即装上。“签名了还是报 no signature file”,先看产物文件名带不带 unsigned,再看 products 里的引用。
5.6 C 内核移植的三处小坑
- SDK cmake 找不到 ninja:交叉编译必须显式
-DCMAKE_MAKE_PROGRAM指向 SDK 内置的 ninja.exe;且 PowerShell 里所有含空格路径的-DKEY=VALUE参数要整体加引号,否则路径从空格处断成两个参数——cmake 报"找不到指定的文件"时,先看参数有没有被拆碎; - git 仓库里的 pcre2.h 是模板:
src/pcre2.h.in带版本占位符,直接 include 必然编译失败——要用 cmake configure 生成的build-ohos/pcre2.h(发行版 tarball 里则是生成好的); - C/ArkTS 各一记类型错:C 侧
int*参数漏取地址,clang 一眼指出;ArkTS 侧对 NAPI 返回值按Record<string, number>收窄后再与true比较,编译器报"number 与 boolean 无重叠"——NAPI 返回值统一按Record<string, Object>收窄再取字段最稳。
六、真机验收:七个镜头
阶段说明:以下截图摄于查询引擎的 ArkTS 原型阶段(Phase 1)。C/PCRE2 内核(Phase 2)合入后,索引与搜索行为保持一致,新增名称命中高亮与状态栏引擎标识,回归要点见 6.7。
6.1 启动与空状态引导

图 1:鸿蒙 PC 桌面上首次启动——空状态页给出"输入以即时搜索"的语法速记和"添加索引目录"主按钮,右下角状态栏与右上角过滤器/刷新就位
当时安装包 270KB 上下(ArkTS 阶段,尚无原生库),覆盖安装后桌面图标即为本应用专属的"放大镜+速度线" logo。
6.2 授权:系统 picker 的"安全访问文件"提示

图 2:点击「+ 添加目录」唤起系统文件夹选择器——顶部是系统注入的「安全访问文件」提示条:“应用仅可访问您选定的文件或文件夹,无法访问文件夹其他内容”
这张截图值得单独说:这句提示不是应用写的,是系统按 picker 授权模型统一注入的。它就是第 5.2 节那套权限取舍的用户可见面——零系统权限 + 用户显式授权 + 系统背书,三件套齐了。
6.3 索引正确性:与系统文件管理器交叉对照

图 3:系统文件管理器打开同一「视频」目录:共 11 个文件
选中「视频」目录后 Worker 全量扫描,索引 11 项。用系统文件管理器打开同一目录逐项对照:11 个视频文件,一一对应,零遗漏零多报。目录胶囊上显示"视频 11 项",点胶囊即可重扫,× 移除该目录。
6.4 即时搜索三连

图 4:输入「光学」——1 项命中,0 ms,命中「境界光学.mp4」

图 5:输入「python」——2 项命中,1 ms,大小写折叠生效

图 6:输入大写「A」——命中「织流Aria下载器(from FDM).mp4」,0 ms
三张图共同的看点是状态栏的延迟数字:0 ms / 1 ms / 0 ms——这是 Worker 内存索引全量扫描的耗时,输入到结果刷新的体感就是"跟着键盘走"。图 6 同时验证了大小写折叠:查询是大写 A,命中名是小写 a 开头的 Aria。
6.5 全量列出与排序

图 7:输入「.」列出全部 11 项——列头「名称▲」排序生效,路径/大小/修改时间四列布局
单字符 . 驱动全量命中的另一层含义是匹配器的短路成本:11 条全扫 + 排序仍 0 ms。列头排序(名称/路径/大小/修改时间,升序降序可切换)在结果集上即时生效。
6.6 验收数据小结
| 操作 | 结果 | 耗时(状态栏实测) |
|---|---|---|
| 添加「视频」目录 → 全量扫描 | 11 项索引,与系统文件管理器逐一对照一致 | 秒级内完成 |
| 搜索「光学」 | 1 项命中 | 0 ms |
| 搜索「python」 | 2 项命中(大小写折叠) | 1 ms |
| 搜索「A」 | 1 项命中(大小写折叠) | 0 ms |
| 搜索「.」 | 11 项全量命中 + 排序 | 0 ms |
6.7 C 内核合入后的回归要点
Phase 2 合入 C/PCRE2 内核后,界面与操作流程不变,回归验收聚焦四点:
- 引擎标识:状态栏应显示
C/PCRE2·JIT或C/PCRE2——这是判断沙箱内 JIT 是否可用的直接证据; - 名称高亮:命中关键词在结果名称中以蓝色加粗渲染,正则/通配符命中同样适用(对应上游 Pango 高亮的移植版实现);
- 行为一致性:11 项索引、大小写折叠命中、
ext:多值等此前用例全部复测通过(C 版的 UCD 折叠语义覆盖更广); - 包体:270KB → 2.5MB,增量来自
libfsearch_napi.so(C 内核 + PCRE2 静态链)约 1.1MB 与libc++_shared.so1.2MB。
七、安全与隐私:零系统权限的授权模型
这套应用的安全设计可以浓缩成一张表:
| 设计点 | 做法 | 效果 |
|---|---|---|
| 权限声明 | requestPermissions: [](连 INTERNET 都没有) | 无任何系统能力面,无 ACL 审核项 |
| 文件访问 | DocumentViewPicker 用户显式授权,范围仅限所选目录 | 应用无法触达任何未授权路径 |
| 授权时效 | 不申请持久化 ACL 权限,重启后重新授权 | 授权窗口最小化 |
| 索引落盘 | 目录登记 JSON 存应用沙箱 filesDir | 数据不出应用、不落外部 |
| 网络 | 无任何网络代码 | 无上传/遥测链路 |
一句话:应用能看见什么,完全由用户某一次亲手点「选择」决定。这也是移植版和上游最大的产品差异——上游面向信任用户的桌面 Linux,可以全盘索引;鸿蒙版面向强沙箱的消费者系统,把"信任边界"交还给系统和用户。
八、已知边界与路线图
诚实列出当前版本的边界:
- 修改时间显示为 1970 年占位值:真机实测对 picker 授权目录
statSync返回的mtime为统一占位(截图中可见),正在跟进正确的元数据获取路径,属已知问题; - 无实时监控:沙箱拿不到 inotify,文件变动需手动重扫(点目录胶囊),后续评估定时增量对比 mtime 的方案;
- 索引规模上限 20 万条 / 深度 14:防失控熔断,大目录场景待真实压测;
- 语法子集:布尔 OR/NOT 与括号组合已随 C 内核落地;
size:/dm:数值日期区间、parent:等字段在路线图上,规格以上游 search_syntax 文档为准,shunting-yard 骨架与 PCRE2 已就位,补节点即可; - 并行分片匹配:当前 C 内核单线程全量扫描,更大规模索引可按 root/区间分片并行(上游 query_node 的多线程 match_data 思路)。
九、QA
Q1:为什么不能像 Windows 的 Everything 一样全盘索引?
A:这是鸿蒙应用沙箱的安全模型决定的:普通应用默认只能访问自己的沙箱目录,其他文件必须经系统文件选择器由用户显式授权。移植版的索引范围 = 用户授权的目录集合,这既是限制也是卖点——应用永远不可能偷看你没授权的任何地方。
Q2:搜索到底有多快?数据量大了还行吗?
A:索引常驻 C 内核(NAPI 进程内调用),匹配在 PCRE2 上执行:子串走预折叠小写线性扫描,正则走 PCRE2(JIT 可用时走 JIT,沙箱受限自动回退解释匹配)。本次验收 11 项目录 0~1 ms;20 万条熔断上限内可以撑住,更大规模可做按目录分片的并行匹配。
Q3:修改时间那一列为什么全是 1970 年?
A:这是当前版本的已知问题:沙箱内对授权目录 statSync 拿到的 mtime 是系统返回的占位值,不是真实修改时间(截图里可见)。文件名/路径/大小的元数据均正确,时间列待修复。
Q4:为什么重启应用后要重新授权目录?
A:授权持久化(fileShare.persistPermission)需要申请受限 ACL 权限 FILE_ACCESS_PERSIST,上架需额外审核。出于最小权限原则主动放弃,换来"零系统权限、零 ACL"的最干净合规面。代价是重启后重新点一次「添加目录」——索引配置仍在沙箱里,重扫一下即恢复。
Q5:文件增删改能自动感知吗?
A:当前不能。沙箱应用无法使用 inotify/fanotify 等内核监控机制(上游 Linux 版的实时监控依赖它们),文件变动需要手动点目录胶囊重扫。定时增量对比在路线图上。
Q6:为什么先用 ArkTS 重写,后来又把 C 内核移植进来?
A:两阶段各有任务。ArkTS 原型用最小代价跑通主闭环,把不确定性收敛到"文件可见性"一个点上,同时验证了查询语法与交互设计;随后把上游查询内核的算法(lexer/parser/树/matcher)剥离 GLib 用纯 C 重建,链接交叉编译的 PCRE2,经 NAPI 进程内调用——正则匹配回到与上游同款的引擎,并解锁了内核侧高亮与后续区间语法的扩展空间。"整包交叉编译 GLib/GIO 全家桶"的方案仍然被排除:主循环、19 个 GObject 信号与"绝对路径主键"索引模型的重接线成本远大于收益。
Q7:单击结果"打开"没反应怎么办?
A:打开是通过系统隐式 Want(ohos.want.action.viewData)把 file:// URI 交给系统分发的,若该类型没有可处理的应用,状态栏会提示失败码——这是系统行为而非应用崩溃。右键菜单里的"复制路径"永远可用。
Q8:我的目录结构和文件名会被上传吗?
A:不会。应用没有任何网络权限也没有网络代码;索引只存在应用私有沙箱 filesDir 里,卸载即消失。目录清单通过 JSON 明文落盘,技术上可以用 hdc 自行查验内容。
Q9:这个项目和上游 FSearch 是什么关系?协议怎么算?
A:查询语法、过滤器规格、数据分层思想继承自上游 FSearch(GPL-2.0-or-later),本项目延续同协议开源并在关于对话框与文档中致谢上游;代码是 ArkTS 的独立实现,非上游代码的翻译。
Q10:想自己构建复现,需要什么?
A:DevEco Studio(SDK 6.0.1(21))打开工程根目录,File → Project Structure → Signing Configs 勾选自动签名(确认 build-profile.json5 的 products 节点带有 "signingConfig": "default" 引用),Run 到 2in1 设备/模拟器即可。命令行构建用工程根的 run-build.cmd(内含 DEVECO_SDK_HOME 与 JDK 路径设置)。C 内核与 PCRE2 静态库已随仓库预编译入库,常规构建无需单独交叉编译;要重编 PCRE2 的话,用 cpp/cmake/ohos-aarch64.cmake 工具链文件执行第 4.6 节的两条 cmake 命令。
Q11:PCRE2 的 JIT 在鸿蒙沙箱里能用吗?
A:分两层:编译期 JIT 支持已编进静态库;运行期是否可用取决于沙箱是否允许可执行内存映射。内核做了自动容错——pcre2_jit_compile 失败即回退解释匹配,两种路径结果完全一致,界面状态栏会显示当前实际引擎(C/PCRE2·JIT / C/PCRE2),一眼可查。
Q12:包体为什么从 270KB 涨到 2.5MB?
A:增加的约 2.2MB = libfsearch_napi.so 1.1MB(C 查询内核 + 静态链入的 PCRE2 10.44)+ libc++_shared.so 1.2MB(NAPI 的 C++ 运行时)。对比上游 Linux 版动辄数 MB 的二进制外加整套 GTK 运行时,这个体积仍然非常克制。
十、结语
这次移植的答案是"重写 + 真移植"的混合:架构层把上游的设计搬过来——core/UI 分层变成 Worker/Native 分层,GObject 信号变成回调字段,GTK headerbar 变成 ArkUI headerbar,GIO 的文件操作变成零权限的 picker 授权模型;内核层则把上游查询算法(lexer → shunting-yard → 表达式树 → matcher)用纯 C 重建并与交叉编译的 PCRE2 静态链接——正则匹配跑的就是与上游同一款引擎,连 JIT 容错语义都一脉相承。上游灵魂里的三样东西被完整保留:即时响应的搜索体验、一门可组合的高级查询语法、一张精心维护的过滤器表。
对鸿蒙 PC 生态而言,这类"算法密集 + 高频交互"的效率工具是很好的试金石:它考验的不是承载某个运行时的能力,而是 Worker 并发、NAPI 内核、交叉编译、文件授权、列表性能这些原生基本功。实测结论是乐观的——毫秒级搜索、零权限合规、2.5MB 的包体,"ArkTS 打底 + C 内核"的混合路线完全立得住。
欢迎通过 Issue 与 PR 一起完善:size:/dm: 区间语法、mtime 修复、大索引压测、并行分片匹配,都在清单上。
把一款深度绑定 Blender 5.2 的 Python 插件,拆成「纯 Python 领域内核 + 嵌入式 CPython 3.12 + ArkTS 原生界面」,最终在 HarmonyOS PC 上跑通「打开
.fds→ 解析 → 编辑 → 校验 → 导出」的完整闭环。本文记录从源码侦察、路线选型、P0/P1/P2 三阶段实现,到真机截图验收与两次典型构建故障排查的全过程,附带可复现代码与 QA 体系。

〇、TL;DR
- 上游 BlenderFDS(现行名 BFDS,v7.2.0,GPL-3.0-or-later)是 NIST FDS 火灾仿真软件的前处理器,109 个 Python 文件、约 1.52 万行,深度绑定 Blender ≥ 5.2。
- 真正可移植的内核是
FDSList/FDSNamelist/FDSParam文本 AST 与约 150 个声明式参数定义;Blender UI、bmesh体素化、BVHTree几何转换必须放弃。 - 实测发现:裸 CPython 下上游零个文件能原样导入(
import bpy会被链式传导),所以剥离内核是一次「导入层手术」,不是复制文件。 - 最终架构:T0 ArkUI 壳(namelist 树 / Canvas 2D / 属性表单)+ T1 嵌入式 CPython 3.12 子进程,ArkTS 与 Python 之间用「每请求独立文件对」的 JSON 协议。
- 成果:P0 领域层验收 16/16,内核协议验收 22/22,
assembleHap构建成功,签名 HAP 41.08 MB;真机跑通「解析 → 编辑 → 校验 → 2D 绘制生成几何 → 3D 等轴测查看 → 缩放平移 → 导出」全闭环(八张截图见第六章)。

一、先搞清楚要搬的到底是什么
NIST 的 FDS(Fire Dynamics Simulator) 是火灾与烟气流动的 CFD 求解器,输入是一个 Fortran namelist 文本文件(.fds),计算结果用 Smokeview 查看。手写这个文件非常痛苦:一堵墙是 XB=2.0,2.2,0,4,0,2.4 这样的六元组,一个房间要拆成若干 MESH 网格,火源是贴在 VENT 上的 SURF,外加 HEAD/TIME/MISC/REAC/DUMP 等上百个参数,坐标或网格一错,算出来的结果就是错的,而且没有图形界面能直观检查。
BlenderFDS(现行名 BFDS)就是为解决这件事而生的:它把 Blender 3D 场景中的墙体、开口、火源、计算网格自动转换成 .fds,也支持反向导入、参数面板编辑、MPI 网格分桶,以及在 Blender 里一键调用外部 fds / smokeview。

问题在于:它不是独立应用,只是 Blender 的扩展插件。要把它搬到鸿蒙 PC,第一反应是「把 Blender 一起搬过去」,而这恰好是最不可行的一条路。动手之前,先把上游源码的真实体量摸清楚(firetools/bfds,HEAD 0ad1088,109 个 .py、约 1.52 万行):
| 目录 | 文件数 | 约行数 | 职责 | 与 Blender 的耦合 |
|---|---|---|---|---|
source/lang/ | 62 | 6,870 | 约 150 个声明式 namelist / 参数定义,MESH 划分、MULT 乘子、GIS 换算 | 35 个文件顶层 from bpy.types/props import ... |
source/types/ | 5 | 1,550 | FDSList / FDSNamelist / FDSParam 文本 AST;BFParam 元数据基类 | bf_param.py、bf_namelist.py 引用 bpy |
source/bl/ + source/ui/ | 26 | 3,990 | Blender Panel / Operator / Header / UIList | 全部是 UI,无法复用 |
source/utils/ | 8 | 1,180 | GIS、IO、几何、装箱、外部命令 | geometry.py 用 bmesh,run.py 用 os.system 起 mpiexec |
source/config.py | 1 | 800 | 精度常量、导出格式、外部命令模板 | 第 3 行 import os, sys, bpy |
真正值钱的是 source/types/fds_list.py(508 行)——一套自洽的 FDS namelist 文本 AST,自带解析与格式化:
# 上游 source/types/fds_list.py:几乎零 bpy 的内核
class FDSList(list):
def to_string(self) -> str:
body = list()
body.extend(self.msgs)
body.extend(item.to_string() for item in self)
body = "\n".join(b for b in body if b)
if self.header and body:
body = "\n".join((self.header, body))
return body
def from_fds(self, f90_namelists=None, f90_params=None, f90_value=None) -> None:
self.clear()
if f90_namelists:
for match in re.finditer(self._RE_SCAN_F90_NAMELISTS, f90_namelists):
label, f90_params = match.groups()
fds_namelist = FDSNamelist(fds_label=label)
fds_namelist.from_fds(f90_params=f90_params)
self.append(fds_namelist)
由此得到两个关键判断:
- 内核值得保留:文本 AST + 约 150 个声明式参数 + 纯数值算法(MESH 划分、MPI 分桶、GIS)质量很高且基本自洽;Blender UI、
bmesh体素化(calc_voxels.py514 行)、BVHTree/KDTree几何转换(ob_to_geom.py391 行)绑死 Blender,必须放弃。 - 「纯 Python 文件」是个陷阱:文本上有 41 个文件不含
bpy,但config.py的import bpy会通过types/__init__.py、utils/__init__.py、lang/__init__.py的链式导入传导到全包——裸 CPython 下当前零个文件能原样导入。所以「抽出领域层」不是复制文件,而是一次导入层手术。
另外还有一个安全发现:上游解析外部 .fds 的参数值时用了 eval()(fds_list.py 第 272 行附近)。留在 Blender 里问题不大,但做成面向用户文件的桌面工具后,这就是一个代码执行入口,必须换掉。
二、路线选型:为什么是「T0 的壳 + T1 的核」
鸿蒙 PC 适配常按迁移深度分三级:T2 在翻译/兼容层下直接运行原始桌面二进制;T1 用嵌入式运行时(CPython / Qt / Electron / Web)承载原有领域代码,仅重写壳层与桥接;T0 用系统原生语言与 UI 框架重写业务逻辑。
Blender 桌面程序在鸿蒙沙箱里没有可承载的兼容层,T2 直接出局。剩下的问题只是「哪些部分走 T1、哪些走 T0」:
| 路线 | 做法 | 结论 |
|---|---|---|
| A 整体搬 Blender 5.2 | 移植 GHOST/OpenGL/EGL 与几百 MB 依赖链 | 否决:无已验证运行时,不可控、不可上架 |
| B Electron / ArkWeb + Pyodide | 把 150 个参数声明翻成 JS,或塞 WASM | 否决:丢掉「跟随上游」能力,包体与 JIT 风险高 |
| C 纯 ArkTS 全量重写 | 连领域层一起重写 | 否决:正则解析、值求值、__init_subclass__ 元数据注册全要重做,语义极易走样 |
| D T0 壳 + T1 核 | ArkUI 原生界面 + 嵌入式 CPython 承载 Python 领域层 | 采用 |
最终架构:

Native 侧复用了同工作区里已真机验证过的成熟链路:childProcessManager.startNativeChildProcess 拉起 libbfds_child.so:Main,子进程 dlopen HAP 内的 libpython3.12.so 后调 Py_BytesMain。相比「静态 .a 整链 + PyRun_SimpleString 捕获 stdout」的方案,这条链路有三个决定性优势:崩溃隔离(解析用户上传的 .fds 出异常不会带走 UI)、常驻状态自然(编辑器需要在内存里保持 namelist 树)、避开子进程 io 写盘不可靠的坑(文件协议只依赖 open/write/fsync,不依赖 stdout 管道)。
明确的功能减法也写进了 README 的边界:不做 Blender 3D 建模与体素化、不做 GEOM/BVH 转换、不内置 FDS/Smokeview 求解器(Fortran + MPI 二进制无法在 HAP 沙箱 execv,filesDir 是 noexec 挂载),产品定位是「FDS 输入文件工作台」,计算交给外部求解器。
三、P0:把领域层从 Blender 里「剥」出来
P0 的目标非常克制:不写一行鸿蒙代码,先证明 import bfds_core 能在裸 CPython 下成功。产物是 p0_core/bfds_core/,77 个 .py / 327 KB。
3.1 用兼容垫片切断 bpy 传导
上游只是把 bpy.types.Scene、bpy.props.FloatProperty 当类元数据(bpy_type = Scene),真正调用 Blender 都在方法体里。所以不必逐文件改 35 个 import,只需在包入口安装一个按需生成符号的垫片,把 bpy / bmesh / mathutils 注册进 sys.modules:
# 代码位置:p0_core/bfds_core/_compat.py(节选)
class _Sentinel:
"""可调用、属性可读写、按需缓存的哑对象。"""
def __init__(self, name):
object.__setattr__(self, "name", name)
object.__setattr__(self, "_cache", {})
def __call__(self, *args, **kwargs):
return self
def __getattr__(self, key):
if key.startswith("__"):
raise AttributeError(key)
cache = object.__getattribute__(self, "_cache")
if key not in cache:
cache[key] = _Sentinel(f"{self.name}.{key}")
return cache[key]
def _module(name, factory):
mod = _types.ModuleType(name)
setattr(mod, "_bfds_compat_stub", True)
cache = {}
def __getattr__(key):
if key.startswith("__"):
raise AttributeError(key)
if key not in cache:
cache[key] = factory(key)
return cache[key]
mod.__getattr__ = __getattr__
sys.modules[name] = mod
return mod
def install():
current = sys.modules.get("bpy")
if current is not None and not getattr(current, "_bfds_compat_stub", False):
return # 真 Blender 环境不覆盖
types_mod = _module("bpy.types", lambda k: type(k, (), {})) # 稳定唯一,供 == 比较
props_mod = _module("bpy.props", lambda k: _prop)
...
_module("bmesh", lambda k: _Sentinel(k))
mathutils = _module("mathutils", lambda k: _Sentinel(k))
mathutils.kdtree = _module("mathutils.kdtree", lambda k: _Sentinel(k))
mathutils.bvhtree = _module("mathutils.bvhtree", lambda k: _Sentinel(k))
bpy.types 用动态 type() 生成稳定唯一的类(供 n.bpy_type == Scene 这类比较),bpy.props 返回哑函数。包入口先装垫片再加载领域层:
# 代码位置:p0_core/bfds_core/__init__.py
from . import _compat
_compat.install()
from . import config, types, utils, lang
from .types import BFException, BFParam, BFNamelist, FDSList, FDSNamelist, FDSParam
3.2 四处必须动刀的地方
| 改造 | 文件 | 原因 |
|---|---|---|
去 import bpy、startup.blend 路径置空 | config.py | 800 行精度/格式常量表保留 |
删 f90nml 残留 import;eval → ast.literal_eval + 类型白名单 | types/fds_list.py | 安全红线(见下) |
切断链式导入;bpy.path 的 // 语义换成沙箱绝对路径 | utils/__init__.py、utils/io.py | 鸿蒙 HAP 没有 .blend 基准路径 |
numpy → array + struct | lang/ON_GEOM/bingeom.py | 消灭唯一的 C 扩展依赖,保持 Fortran record 字节布局不变 |
eval 补丁是全文最重要的一处安全修改——外部 .fds 是用户文件,绝不能执行其中的表达式:
# 代码位置:p0_core/bfds_core/types/fds_list.py(改造后)
else:
# 只接受字面量,禁止对外部 .fds 值执行任意表达式
try:
value = ast.literal_eval(v)
if not isinstance(value, (bool, int, float, str)):
raise ValueError(
f"unsupported value type <{type(value).__name__}>"
)
except Exception as err:
msg = f"Malformed FDS file: <{self.fds_label}={f90_value}> (value: <{v}>)\n<{err}>"
raise BFException(self, msg)
values[i] = value
bingeom.py 的去 numpy 改写保持字节级兼容(Fortran unformatted sequential record:4 字节长度 tag + 数据 + 4 字节 tag):
# 代码位置:p0_core/bfds_core/lang/ON_GEOM/bingeom.py(节选)
_FORMATS = {"int32": ("i", 4), "float64": ("d", 8)}
def _read_record(f, req_dtype, req_dlen):
typecode, itemsize = _check_format(req_dtype)
tag = struct.unpack("i", f.read(4))[0]
dlen = tag // itemsize
if dlen != req_dlen:
raise IOError(f"Different requested and declared record length: {req_dlen}, {dlen}")
data = array.array(typecode)
data.frombytes(f.read(tag))
end_tag = struct.unpack("i", f.read(4))[0]
if tag != end_tag:
raise IOError(f"Different start and end record tags: {tag}, {end_tag}")
return data
3.3 剥离要可重放,不要一次性手艺
上游在演进,手工剥离副本很快就会失联。所以 P0 的最后一步是把剥离过程固化成幂等脚本:复制领域层子集 → 重放五处补丁 → 绝不覆盖手写的垫片与沙箱 IO。
# 代码位置:tools/sync_from_upstream.py(节选)
def patch(path, replacements):
with open(path, "r", encoding="utf-8") as f:
text = f.read()
changed = False
for old, new in replacements:
if old in text:
text = text.replace(old, new, 1)
changed = True
if changed:
with open(path, "w", encoding="utf-8", newline="\n") as f:
f.write(text)
return changed
# shutil.copytree(..., ignore=shutil.ignore_patterns("bingeom.py", "__init__.py", "io.py", ...))
# print(f"synced {CORE}; re-patched: {changed or 'nothing (already clean)'}")
3.4 P0 验收:16 项全绿
验收脚本 verify_roundtrip.py 不依赖 Blender、不依赖 DevEco、不依赖任何第三方包,覆盖导入、元数据、往返幂等、数值工具、bingeom 读写与安全解析:
| # | 检查项 | 结果 |
|---|---|---|
| 1 | bfds_core 可在裸 CPython 导入 | PASS |
| 2 | bpy 已替换为兼容垫片 | PASS |
| 3 | 内核未导入 numpy(零 C 扩展) | PASS |
| 4 | 领域层元数据加载(BFParam=135,BFNamelist=30) | PASS |
| 5 | 声明式 namelist 可检索(ON_OBST / OP_XB) | PASS |
| 6-8 | 12 个 namelist 解析 / 生成 / 解析→生成→解析幂等 | PASS |
| 9-12 | 字符串 / 布尔 / 多值浮点坐标 / 整数 / 多行 namelist 保持 | PASS |
| 13 | MPI 分桶 binpack 可用 | PASS |
| 14 | bingeom 读写往返(array/struct,172 字节) | PASS |
| 15 | 恶意 .fds 值 __import__('os').system(...) 被拒绝 | PASS |
| 16 | 静态复核无 subprocess / os.system / f90nml / eval( | PASS |
这一步通过,才判定整条路线值得继续——它同时回答了「领域层能不能脱离 Blender」和「要不要一次性把 15k 行翻成 ArkTS」两个问题。
四、P1:HAP 骨架与 Native 子进程
4.1 工程骨架
照抄已验证模板的关键红线:bundleName org.blenderfds.ohos、deviceTypes ["2in1","tablet"]、compatibleSdkVersion 6.0.1(21) / targetSdkVersion 6.0.2(22)、requestPermissions: [](零权限)、nativeLib.collectAllLibs: true + 两处 debugSymbol.strip: false(被 strip 的 libpython 在 musl 上 dlopen 会 SIGSEGV)。
# 代码位置:entry/src/main/cpp/CMakeLists.txt
cmake_minimum_required(VERSION 3.5.0)
project(bfds_bridge)
set(NATIVERENDER_ROOT_PATH ${CMAKE_CURRENT_SOURCE_DIR})
set(CMAKE_CXX_STANDARD 17)
add_library(bfds_bridge SHARED bfds_bridge.cpp)
target_compile_definitions(bfds_bridge PRIVATE _GNU_SOURCE)
target_link_libraries(bfds_bridge PUBLIC libace_napi.z.so libhilog_ndk.z.so)
add_library(bfds_child SHARED bfds_child.cpp)
target_compile_definitions(bfds_child PRIVATE _GNU_SOURCE)
target_link_libraries(bfds_child PUBLIC libhilog_ndk.z.so libdl.so)
libbfds_bridge.so 导出 createChannel/closeFd/readFd/writeFd/readUntilEof/ensureExecutable 六个 NAPI 方法,createChannel 用 socketpair(AF_UNIX, SOCK_STREAM, 0, fds) 把两个 fd 直接作为 JS 对象属性返回(沙箱里 posix_openpt 返回 EPERM,socketpair 是可行解)。libbfds_child.so 导出 Main(NativeChildProcess_Args),从 fd 表取 terminal/boot/out/exit,dup2 接管 stdout/stderr,dlopen 按 libpython3.12.so.1.0 → libpython3.12.so 顺序尝试,最后 Py_SetPythonHome + Py_BytesMain 进入解释器。entryParams 全部 UTF-8 hex 编码、以 00(NUL)拼接,规避 NUL 与中文路径问题。
4.2 每请求独立文件对:协议的核心
ArkTS 与 Python 之间不用 socket 传 JSON,而是文件队列:请求 bfds_req_<seq>.json,响应 bfds_resp_<seq>.json(临时文件 + os.replace 原子发布)。为什么必须一请求一文件对? 因为单共享请求文件会被排队请求覆盖,内核读到的是下一条请求,上一条被静默吞掉——这是真机调试时用日志埋点抓出来的问题,坑在「没有任何报错,只是请求消失了」。
# 代码位置:python_kernel/bfds_server.py(节选)
def _pending():
names = [n for n in os.listdir(BASE)
if n.startswith(REQ_PREFIX) and n.endswith(".json")]
names.sort()
return os.path.join(BASE, names[0]) if names else None
def _load(path):
for _ in range(20): # 半写 JSON 重试
try:
with open(path, "r", encoding="utf-8") as f:
return json.loads(f.read())
except ValueError:
time.sleep(0.01)
except OSError:
return None
return None
def _emit(seq, result):
tmp = os.path.join(BASE, RESP_PREFIX + seq + ".json.tmp")
target = os.path.join(BASE, RESP_PREFIX + seq + ".json")
with open(tmp, "w", encoding="utf-8") as f:
json.dump({"seq": seq, "result": result}, f)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, target) # 原子发布
ArkTS 侧对应实现:temp-then-rename 写请求、15ms 轮询响应、双删文件、Promise 链串行化所有请求:
// 代码位置:entry/src/main/ets/pages/Index.ets(节选)
const seq = `${Date.now()}_${++this.requestSerial}_${fn}`;
const reqPath = this.kernelFile(`bfds_req_${seq}.json`);
const respPath = this.kernelFile(`bfds_resp_${seq}.json`);
const tmpPath = reqPath + '.tmp';
writeTextFile(tmpPath, payload);
try {
fileIo.moveFileSync(tmpPath, reqPath);
} catch (_) {
writeTextFile(reqPath, payload);
}
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (!pathExists(readyPath)) {
throw new Error('kernel died');
}
if (pathExists(respPath)) {
const env = JSON.parse(readTextFile(respPath)) as Record<string, Object>;
const out = JSON.stringify(env['result'] ?? { ok: false, error: 'empty result' });
fileIo.unlinkSync(respPath);
fileIo.unlinkSync(reqPath);
return out;
}
await sleepMs(15);
}
4.3 server 源码不要手工内嵌进 ArkTS
模板工程曾把 server 源码手工内嵌成 ArkTS 字符串数组,多行字符串的缩进一旦断裂,设备端只会静默 SyntaxError,排查成本极高。这里改成从真实 .py 文件生成:
# 代码位置:tools/gen_py_server.py(核心三行)
with open(SRC, "r", encoding="utf-8") as f:
lines = f.read().splitlines()
body = ",\n".join(" " + json.dumps(line, ensure_ascii=False) for line in lines)
text = "export const PY_SERVER: string = [\n" + body + "\n].join('\\n');\n"
生成出的 PyServer.ets 再被验收脚本反向提取并 compile(),保证「ArkTS 里内嵌的 Python」永远语法正确。
4.4 运行时包与 marker
build_runtime.py 从源运行时包提取 aarch64-linux-ohos 的 CPython 3.12 stdlib,放进 bfds_core/(77 个文件)与 bfds_engine.py,用 tar -a -cf 打成 bfds_python_runtime.zip(8.78 MB / 808 条目)——不能用 PowerShell 的 Compress-Archive,它会写反斜杠路径,设备端解压后 import 全挂。首启由 ArkTS 解压到 filesDir,用 marker 文件(READY_BFDS_2)判断是否可复用;每次改 Python 必须递增 marker,否则设备端永远沿用旧解压目录。
# 代码位置:tools/build_runtime.py(节选)
shutil.copytree(CORE_SRC, os.path.join(sp, "bfds_core"),
ignore=shutil.ignore_patterns("__pycache__", "*.pyc"), dirs_exist_ok=True)
shutil.copy(ENGINE_SRC, os.path.join(sp, "bfds_engine.py"))
subprocess.run(["C:/Windows/System32/tar.exe", "-a", "-cf", OUT_ZIP, "ohos_python"],
cwd=RTMP, check=True)
P1 验收在宿主 CPython 上模拟设备端文件协议:从 PyServer.ets 提取 server 源码 compile() 通过 → 复制内核到临时目录 → 写请求文件 → 调 _serve_one() → 读响应文件。probe_state / get_meta / sample_fds / parse_fds / export_fds / open_fds / 未知 fn 结构化错误 / 零 numpy 9/9 通过。随后 assembleHap 构建成功,HAP 内核对:runtime zip、两个桥接 so、libpython3.12.so(.1.0)、64 个 libs/arm64 条目一个不少。
五、P2:三栏工作台
5.1 内核新增三个接口
编辑闭环要求「改参数值 → 重建 .fds → 校验 → 驱动画布」,所以在 bfds_engine.py 里补了 build_fds / validate_fds / get_geometry。用户在属性框里编辑的是字符串,回写时经引号保护的逗号拆分 + 字面量求值还原成 FDS 值,再走上游 AST 的 to_string(),保证格式与领域层完全一致:
# 代码位置:python_kernel/bfds_engine.py(节选)
def _split_values(raw):
"""Split a comma-separated FDS value while protecting quoted strings."""
out, buf, quote = [], "", None
for ch in raw:
if quote:
buf += ch
if ch == quote:
quote = None
elif ch in ("'", '"'):
quote, buf = ch, buf + ch
elif ch == ",":
out.append(buf); buf = ""
else:
buf += ch
out.append(buf)
return [p.strip() for p in out if p.strip()]
def _build(items):
"""Rebuild a formatted .fds from the structured namelist tree."""
tree = FDSList()
for item in items or []:
label = str(item.get("label", "")).upper()
params = []
for p in item.get("params", []) or []:
plabel = str(p.get("label", "")).upper()
if not plabel:
continue
params.append(FDSParam(fds_label=plabel, value=_coerce(p.get("value"))))
tree.append(FDSNamelist(fds_label=label, iterable=params))
return {"ok": True, "text": tree.to_string(), "count": len(tree)}
校验覆盖四类真实错误源:namelist 标签必须 4 位大写字母/数字、XB 必须是 6 个数值且满足 x0≤x1, y0≤y1, z0≤z1、IJK 必须是 3 个正整数、SURF_ID 引用必须能找到对应 &SURF:
# 代码位置:python_kernel/bfds_engine.py(节选)
if "XB" in pm:
xb = _num_list(pm["XB"])
if xb is None or len(xb) != 6:
issues.append({"level": "error", "where": where, "message": "XB 必须是 6 个数值"})
elif not (xb[0] <= xb[1] and xb[2] <= xb[3] and xb[4] <= xb[5]):
issues.append({"level": "error", "where": where,
"message": "XB 边界须满足 x0≤x1, y0≤y1, z0≤z1"})
if "SURF_ID" in pm and surf_ids:
sid = _text(pm["SURF_ID"])
if sid and sid not in surf_ids:
issues.append({"level": "warn", "where": where,
"message": f"SURF_ID='{sid}' 未找到对应 &SURF"})
5.2 ArkUI 三栏与 Canvas 2D
界面是标准 PC 三栏:左侧 namelist 树(24%)、中间 Canvas 计算域 + FDS 文本编辑区、右侧属性表单(30%)+ 校验问题列表。Canvas 把每个 MESH 的 XB 矩形按 IJK 画网格、OBST/VENT/HOLE 按类型着色、选中项橙色高亮,边界自适应缩放:
// 代码位置:entry/src/main/ets/pages/Index.ets(节选)
private drawScene(): void {
const ctx = this.canvasCtx;
const w = this.canvasW > 0 ? this.canvasW : Number(ctx.width);
const h = this.canvasH > 0 ? this.canvasH : Number(ctx.height);
if (w <= 0 || h <= 0) { return; }
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, w, h);
const pad = 18;
const spanX = Math.max(this.xmax - this.xmin, 0.000001);
const spanY = Math.max(this.ymax - this.ymin, 0.000001);
const scale = Math.min((w - 2 * pad) / spanX, (h - 2 * pad) / spanY);
...
for (let i = 0; i < this.meshes.length; i++) {
const m = this.meshes[i];
const r = this.rectOf(m.xb);
ctx.strokeStyle = '#1565c0'; // MESH 外框
ctx.lineWidth = 2;
ctx.strokeRect(r.x, r.y, r.w, r.h);
ctx.strokeStyle = '#dbe6f6'; // IJK 单元网格
const ni = Math.min(m.ijk[0], 40);
for (let a = 1; a < ni; a++) {
const x = m.xb[0] + (m.xb[1] - m.xb[0]) * a / ni;
ctx.beginPath();
ctx.moveTo(this.px(x), this.py(m.xb[2]));
ctx.lineTo(this.px(x), this.py(m.xb[3]));
ctx.stroke();
}
}
}
属性编辑有个容易踩的 ArkUI 坑:TextInput 直接双向绑定数组元素会丢焦点。解法是把编辑值放进独立的 @State propValues: string[],选中项切换时整体赋值触发刷新,输入过程中只按下标静默更新(不触发重建,焦点不丢),点「应用编辑」才整体写回结构树并请求 build_fds。
另存为走零权限 picker(DocumentViewPicker.save + DocumentSaveOptions,newFileNames / fileSuffixChoices),对话框预创建空文件并返回 uri,应用随后写入:
// 代码位置:entry/src/main/ets/pages/Index.ets(节选)
const docPicker = new picker.DocumentViewPicker(this.context);
const opts = new picker.DocumentSaveOptions();
opts.newFileNames = ['case.fds'];
opts.fileSuffixChoices = ['FDS 输入文件|.fds'];
const uris: Array<string> = await docPicker.save(opts);
if (uris.length === 0) { return; }
const file = fileIo.openSync(uris[0],
fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.TRUNC);
try {
const data = new util.TextEncoder().encode(this.fdsText);
fileIo.writeSync(file.fd, data.buffer);
} finally {
fileIo.closeSync(file);
}
六、真机验收
八张截图分两组:前四张对应 P2 的完整验收路径(冷启动 → 加载示例 → 最大化编辑 → 切换选中联动);6.2 节的四张对应 P3 的 3D 视图与交互打磨(放大细节 → 3D 选中联动 → 小窗口方位 → 缩小总览)。

图 1 · 冷启动。窗口标题「焰匣 FDS · 火灾仿真输入文件工作台」,右上角状态为「内核就绪 | T1 · CPython 3.12 · 1.0.0」。此时已经完成:runtime zip 首启解压(marker 校验)、childProcessManager 拉起 libbfds_child.so:Main、dlopen libpython、server 写出 bfds_srv_ready.txt。底部日志打印了「内核就绪(常驻进程)」以及解释器版本与 BFDS 7.2.0 的握手信息——这两行是整条 T1 链路活着的第一手证据。三栏均为空态,等待载入模型。

图 2 · 加载示例。点「加载示例」后,左栏出现 9 个 namelist(&HEAD/&TIME/&MISC/&MESH/&SURF/&OBST/&VENT/&DEVC/&TAIL),每个都带实时统计的参数个数;中间画布画出蓝色 MESH 外框、浅蓝 IJK 单元网格、灰色 OBST 盒体与火源 VENT;右栏自动选中 &OBST · #6,属性表单展示 ID / XB / SURF_ID 三个参数。注意右下角校验栏:0 错误 / 1 警告,警告内容是 &OBST 的 SURF_ID='WALL' 未找到对应 &SURF——这不是 bug,而是校验引擎在工作:内置示例故意只定义了 &SURF ID='FIRE',WALL 是一个悬空引用,正好用来演示 warn 级别语义检查。

图 3 · 最大化编辑。窗口最大化后三栏按 24% / 自适应 / 30% 稳定伸缩,Canvas 随 onReady 重新取宽高并重绘。底部 FDS 文本区显示的是规范化后的 .fds(多行 MESH 合并、精度对齐、&TAIL / 收尾),它是从结构树经 build_fds 重新生成的——也就是说,文本区、结构树、画布三份数据同源,任何一处编辑都能通过「解析文本 / 应用编辑」双向同步。日志区可见连续多次「应用编辑 → 解析 → 规范化」操作,全部走常驻内核的文件协议完成。

图 4 · 联动。点选左栏 &VENT · #7,右栏属性切换为 ID=BURNER / XB / SURF_ID=FIRE,画布上的橙色高亮框同步移动到火源盒体上。namelist 树 → 属性表单 → 画布三者联动,是这套「结构化编辑」区别于纯文本编辑器的核心价值:改一个 XB,立刻能看到它在计算域里的位置和尺寸是否合理。
6.2 P3 补记:把「3D」与「可操作」搬进画布
P2 交付后,真机反馈集中在三件事:3D 图像显示不全、中间画布不能移动放大、按钮占了两行。这一轮把三个问题一起收敛,顺带把「2D 拖拽生成几何」和「3D 等轴测视图」补成了完整闭环。下面四张截图是这一轮的真机结果。
(1) 3D 等轴测:纯 Canvas 数学,不引入 OpenGL
等轴测的关键只有两行变换:先绕 Z 轴旋转方位角,再把 (x−y) 与 (x+y)/2−z 投到屏幕坐标,最后用包围盒 8 个角点自适应缩放居中。
private isoRaw(x: number, y: number, z: number): number[] {
const a = this.isoAzimuth; // 方位角,每次点「旋转」+45°
const xr = x * Math.cos(a) - y * Math.sin(a);
const yr = x * Math.sin(a) + y * Math.cos(a);
return [(xr - yr) * 0.8660254, // cos30°
(xr + yr) * 0.5 - z]; // sin30°,z 向上
}
每个 XB 盒体算 8 个顶点 → 三个可见面按明/中/暗透明度填充 → 12 条边线框;所有盒体按 x+y+z 深度排序,近的覆盖远的。因此不需要 XComponent / OpenGL ES / 任何新运行时,真机风险几乎为零。留白从 26px 提到 52px 并再乘 0.88 安全系数,配合 onAreaChange 实时取画布宽高,旋转到任意方位都不贴边。

图 5 · 放大平移。方位 90°、缩放 156%(工具栏倍率按钮可见):灰色 OBST 是一块贯穿整层 z 范围的墙板,红色小方块是火源 VENT,蓝色线框是 MESH 计算域。放大后蓝色线框有意超出画布——这正是「可移动查看」的意义:用浏览模式拖拽把感兴趣的区域拖到视野中央,看局部细节,而不是被强行缩到一屏里。

图 6 · 3D 联动。方位 0°、100%:左栏选中 &OBST · #6,右栏显示 ID=WALL / XB / SURF_ID=WALL,画布上橙色粗框套住整块墙板,MXSH 蓝色线框与红色火源的空间关系一目了然。2D 视图里的「树 → 属性 → 画布」联动,在 3D 等轴测下完全复用同一份 get_geometry 数据。

图 7 · 小窗口与方位 180°。把窗口拖回普通尺寸、切到 180° 方位,三栏仍按 24% / 自适应 / 30% 稳定伸缩,画布上方的状态行显示「3D 等轴测 · 方位 180°」。这说明布局是靠 layoutWeight 与百分比宽度做的自适应,不是为某一个窗口尺寸写死的坐标。

图 8 · 缩小总览。方位 225°、缩放 64%:主动缩小后完整看清 MESH 外框、贯穿整层的 OBST 板与火源的相对位置。配合图 5 的 156% 放大,构成「总览 / 细节」两种观察尺度。
(2) 缩放、平移与绘制精度
2D 与 3D 两套坐标变换都纳入同一组 zoom / panX / panY,并且反算函数同步带上缩放平移——这是最容易被忽略的一处:只改正向绘制、不改反算,放大后拖拽画出来的 XB 就会落到错误位置。
private px(u: number): number {
return this.plotOx + (u - this.curUMin) * this.plotScale * this.zoom + this.panX;
}
private screenToU(sx: number): number {
return this.curUMin + (sx - this.plotOx - this.panX) / (this.plotScale * this.zoom);
}
浏览模式(drawMode === '')拖拽 = 平移;绘制模式拖拽 = 生成盒体,onTouch 的 Down/Move/Up 分两条路径互不干扰。缩放范围 0.3×–5×、步长 1.25×,点倍率按钮一键复位。
(3) 单行工具栏:用循环态按钮压缩交互
原来两行共 13 个按钮,一行放不下且拥挤。改成 12 个按钮一行后,用状态循环代替平铺:
| 按钮 | 循环态 |
|---|---|
| 画盒体 | 画 OBST → 画 VENT → 取消绘制 → …… |
| XY 平面 | XY → XZ → YZ → 3D 等轴测 → XY …… |
| 100% | 显示当前倍率,点一下复位 |
「规范化」并入「应用编辑」(两者都走 build_fds 重建文本),「旋转」在任意视图下都能直接进入 3D 并转 45°。画布内原本压在图形顶部的提示文字移到画布外的状态行,这同时解决了「文字盖住 3D 顶点」的观感问题。
(4) 2D 拖拽生成几何:从「看」到「画」
内核新增 add_box / update_xb / delete_namelist 三个接口与 get_geometry 的 z 边界。画布矩形按当前投影面组装成 XB,第三轴自动取覆盖 MESH 的整层范围,六个值再按该 MESH 的 IJK 单元尺寸对齐到网格边界——这就是上游「Voxel Size」的语义:
def _snap(value, origin, cell, lo, hi):
v = origin + round((value - origin) / cell) * cell
return min(hi, max(lo, round(v, 6)))
实测拖拽 1.23 → 1.77 在 IJK=60,40,30、XB=0..6 的模型里对齐成 1.2 → 1.8(cell=0.1),新 namelist 自动命名、自动插入 &TAIL 之前,树 / 文本 / 画布 / 校验一次刷新。
这一轮的验收:内核协议从 15 项扩到 22/22(新增 z 边界、add_box、snap 对齐、TAIL 前插入、update_xb、delete、非法平面 7 项),P0 仍 16/16,BUILD SUCCESSFUL 且 SignHap 正常执行。Python 内核改了三处接口,marker 同步递增到 READY_BFDS_3——这条流程红线在第五章已经说过,这一轮又踩实了一次。
七、踩坑实录
这一章是全文最贵的部分,每一条都是真实发生过的。
7.1 ohpm 00640001: Cannot convert undefined or null to object
DevEco 打开工程时执行 ohpm install --all 直接崩。根因不是网络也不是 SSL,而是手写的 oh-package-lock.json5 不合格:
- 根 lock 只有
lockfileVersion和packages,缺meta/specifiers字段,ohpm 对undefined做Object.keys()就崩; - 完全没有
entry/oh-package-lock.json5,而entry/oh-package.json5声明了本地file:依赖libbfds_bridge.so。
CLI 构建能成功是因为 hvigor 直接走本地源码路径,没触发 ohpm 完整解析——所以这个问题只在 IDE 里暴露。修复就是把两个 lock 文件按模板补齐:
// 代码位置:entry/oh-package-lock.json5(修复后)
{
"meta": { "stableOrder": true, "enableUnifiedLockfile": false },
"lockfileVersion": 3,
"specifiers": {
"libbfds_bridge.so@src/main/cpp/types/libbfds_bridge":
"libbfds_bridge.so@src/main/cpp/types/libbfds_bridge"
},
"packages": {
"libbfds_bridge.so@src/main/cpp/types/libbfds_bridge": {
"name": "libbfds_bridge.so",
"version": "1.0.0",
"resolved": "src/main/cpp/types/libbfds_bridge",
"registryType": "local"
}
}
}
修完复跑 install completed in 3s 433ms,而且之前「NAPI 模块未验证」的编译警告也消失了——lock 正确后 d.ts 才会被真正解析。
7.2 9568320: no signature file
自动签名做完,真机安装仍报「无签名文件」。日志里的决定性证据是 DevEco 推送的还是 entry-default-unsigned.hap。排查发现:自动签名把材料写进了 app.signingConfigs,但 app.products[0] 没有 "signingConfig": "default" 引用,SignHap 任务直接跳过。补一行引用,SignHap 实际执行 19.9 秒,产出 entry-default-signed.hap(41.08 MB,比 unsigned 多出的即签名块)。
// 代码位置:build-profile.json5(结构示意,密码切勿入库)
{
"app": {
"products": [
{
"name": "default",
"signingConfig": "default", // ← 缺了这一行,签名永远不执行
"compatibleSdkVersion": "6.0.1(21)",
...
}
],
"signingConfigs": [
{
"name": "default",
"type": "HarmonyOS",
"material": {
"storeFile": "<自动签名生成的 .p12>",
"storePassword": "<加密串>",
"keyAlias": "debugKey",
"keyPassword": "<加密串>",
"profile": "<绑定 bundleName 的 .p7b>",
"certpath": "<.cer>"
}
}
]
}
}
连带教训:build-profile.json5 从此含本机材料路径与加密密码,公开仓库推送前必须清空 signingConfigs。
7.3 其余按代价排序的坑
| 坑 | 现象 | 解法 |
|---|---|---|
| 共享单个请求文件 | 排队请求被覆盖,请求静默消失,无任何报错 | 每请求独立 bfds_req_<seq>.json,响应后双删 |
| 手工内嵌 Python 到 ArkTS | 缩进断裂 → 设备端静默 SyntaxError | 从真实 .py 生成字符串常量,验收脚本反向 compile() |
| runtime marker 不递增 | 改了 Python,设备端永远用旧解压目录 | 每次重打 zip 手工递增 READY_BFDS_2 |
Compress-Archive 打 zip | 反斜杠路径,设备端 import 全挂 | tar.exe -a -cf |
strip: false 被忽略 | libpython 被 strip 后 dlopen SIGSEGV | 根/模块两处 debugSymbol.strip: false 都要写 |
| libs 目录混入非 so | collectAllLibs 全打包,包体暴涨 | 复制时过滤 .so / .so.x |
| ArkUI 属性链 | 读 @State 的宽高不生效、属性链里调私有方法被静默忽略 | 宽度用三元字面量百分比;绘制尺寸在 onReady 里读画布实际值 |
TextInput 双向绑定数组元素 | 编辑丢焦点 | 编辑值放独立 @State 数组,输入只按下标静默更新 |
画布尺寸只在 onReady 取一次 | 窗口最大化后 3D 线框贴边、被裁 | 加 onAreaChange 实时更新宽高并重绘;留白 26→52px + 0.88 安全系数 |
| 缩放只改正向绘制、不改反算 | 放大后拖拽生成的 XB 落到错误位置 | px/py 与 screenToU/screenToV 同步纳入 zoom/pan |
| 画在画布顶部的提示文字 | 压住 3D 顶点,看起来像「显示不全」 | 提示移到画布外状态行 |
| 工具栏两行 13 个按钮 | 拥挤、占掉画布高度 | 循环态按钮(绘制三态 / 视图四态)压缩为 12 个单行按钮 |
八、质量保障(QA)
8.1 三层测试金字塔
| 层 | 载体 | 数量 | 覆盖 |
|---|---|---|---|
| L1 领域层 | p0_core/verify_roundtrip.py(宿主 CPython) | 16 项 | 裸导入、垫片、零 numpy、150 个元数据、解析/生成/幂等、bingeom 往返、eval 移除 |
| L2 内核协议 | tools/test_kernel.py(宿主模拟设备文件协议) | 22 项 | 内嵌 Python compile()、文件协议读写、结构化重建、编辑生效、校验、几何提取、z 边界、add_box/update_xb/delete_namelist、snap 对齐 |
| L3 构建/产物 | CLI 构建断言 + 包内容核对 | 逐项 | BUILD SUCCESSFUL、HAP 内 runtime zip 条目数、P2/P3 接口存在性、libs 纯净度 |
| L4 真机 | DevEco 部署 + 人工验收路径 | 2 条主线 | ① 冷启动握手 → 加载示例 → 编辑回写 → 校验警告 → 联动 → 另存;② 2D 拖拽画盒体 → 切 3D 等轴测 → 旋转方位 → 缩放平移 → 单行工具栏 |
关键设计:L1/L2 不依赖 DevEco 与真机,任何一次内核改动都能在几十秒内回归;L3 用脚本核对 HAP 内容而不是靠肉眼;L4 只验收「只有真机能回答的问题」(Canvas 取宽高、触摸手势、picker、焦点、签名)。P3 这一轮的三个真机反馈——3D 贴边、不能平移缩放、按钮两行——恰恰都属于 L4 才能发现的问题,修复后再补进 L2/L3 的回归项。
8.2 安全红线
- 无
eval:外部.fds值一律ast.literal_eval+(bool, int, float, str)白名单,恶意 payload 实测被BFException拒绝。 - 零权限:
module.json5的requestPermissions为空数组,用户文件全部经DocumentViewPicker授权,模型文件落在应用沙箱filesDir。 - 无子进程执行:内核不发子进程、不
execv,FDS 求解器明确不在端侧。 - 凭据不入库:签名材料的绝对路径与加密密码只存在于本地
build-profile.json5,公开仓库前清空;.gitignore排除*.p12/*.p7b/*.cer/*.keystore/.ohos/、oh_modules/、entry/libs/、**/*.zip、**/*.so、_rtmp/、*.hap。
8.3 可复现命令
# 0) 上游同步 + 内核回归(不依赖 DevEco)
python tools/sync_from_upstream.py
python p0_core/verify_roundtrip.py # 16/16
# 1) Python 侧改动后:重生成内嵌 server → 内核协议回归 → 重打运行时包(记得递增 marker)
python tools/gen_py_server.py
python tools/test_kernel.py # 22/22
python tools/build_runtime.py
# 2) 依赖解析(DevEco 打开工程会自动执行)
& "C:\Program Files\Huawei\DevEco Studio\tools\ohpm\bin\ohpm.bat" install --all `
--registry https://repo.harmonyos.com/ohpm/ --strict_ssl true
# 3) CLI 构建(需要 jbr 前置 PATH 与 DEVECO_SDK_HOME)
$env:DEVECO_SDK_HOME = "C:\Program Files\Huawei\DevEco Studio\sdk"
$env:JAVA_HOME = "C:\Program Files\Huawei\DevEco Studio\jbr"
$env:PATH = "C:\Program Files\Huawei\DevEco Studio\jbr\bin;$env:PATH"
& "C:\Program Files\Huawei\DevEco Studio\tools\hvigor\bin\hvigorw.bat" `
--mode module -p module=entry@default -p product=default assembleHap --no-daemon
九、Q&A
Q1:为什么不把 Blender 本体一起移植?
Blender 5.2 依赖 GHOST 窗口系统、OpenGL/EGL 与几百 MB 第三方库,鸿蒙沙箱没有能承载它的兼容层(T2 路线)。移植它的工作量与风险远超插件本身,而且 BlenderFDS 的价值在领域层(FDS 语义),不在 Blender 的 3D 视口。
Q2:为什么用 CPython 子进程而不是把 Python 静态链进 NAPI .so?
两条路都通,但子进程方案已在本工作区同类项目上真机验证:崩溃隔离(解析用户文件挂了不带 UI)、常驻状态自然、文件协议只依赖 open/write/fsync,避开了 fork 后 io 层写盘静默丢失的坑。静态整链的 stdout 捕获协议当时未经真机验证,不值得为一个未验证的方案冒险。
Q3:既然要重写 UI,为什么不连领域层一起用 ArkTS 重写?
领域层的价值恰恰在「跟随上游」:150 个参数的默认值、精度、导出顺序、校验语义都在上游持续演进。翻成 ArkTS 等于永久分叉,上游每次更新都要人肉同步。T1 内核让「跟随上游」变成跑一次 sync_from_upstream.py。
Q4:ArkTS 和 Python 之间为什么用文件而不是 socket/管道?
文件协议可以用文本编辑器直接检查,调试成本最低;os.replace 保证响应原子可见;每请求独立文件对解决排队覆盖问题。socketpair 只用于把 stdio fd 交给子进程捕获启动日志,不承载数据面。
Q5:为什么每个请求一个文件对,这么「浪费」?
因为共享单个请求文件会被排队请求覆盖——内核读到的是下一条请求,上一条被静默吞掉。文件系统的原子 rename 语义天然适合「写临时文件再改名发布」,一请求一对是最便宜的正确解。
Q6:runtime zip 为什么需要 marker 文件?
解压是幂等成本高的操作,marker 用于跳过重复解压;但 zip 内容变了而 marker 没变,设备端就会永远沿用旧解压目录——所以「改 Python 必递增 marker」是流程红线,这条坑过一次才变成规矩。
Q7:兼容垫片会不会掩盖真问题?
垫片只在导入层生效:它让 import bfds_core 成功、让类元数据可比较。任何真正调用 Blender 的方法体在运行时依然会暴露(拿到的是哑对象)。P0 验收只承诺「解析/生成/元数据」路径可用,这恰好就是产品一期需要的全部能力。
Q8:为什么不做 3D 建模和体素化?
上游的体素化依赖 Blender 的 remesh modifier 与 bmesh,等价重写需要自研网格内核(估计 4-8 人周起步)。一期的核心价值是「读懂/编辑/生成 .fds」,2D 计算域视图已经能满足参数校验需求;3D 属于 P3,且应该走自研纯 Python 几何内核,而不是模拟 Blender。
Q9:能把 FDS 求解器内置进去吗?
不能,也不应该。FDS 是 Fortran + MPI + OpenMP 的本地二进制,HAP 沙箱 filesDir 以 noexec 挂载、HNP 二进制不能被 execv。正确姿势是把「求解」留给外部/远程 FDS,端侧专注输入文件的编写与校验——这也符合工具链的分工。
Q10:GPL-3.0-or-later 对这个项目意味着什么?
内核复用了上游源码并随 HAP 分发(嵌入 CPython 进程内调用),按 GPL 需要开源相应部分:仓库保留上游 LICENSE 与版权声明(Emanuele Gissi / firetools),ArkTS 壳以同许可证发布,README 明确上游来源与本地补丁清单。这也是选择公开仓库的原因之一。
Q11:41 MB 的包体还能再小吗?
大头是 CPython 3.12 运行时(libpython + 55 个 C 扩展 so)与 stdlib zip。继续压缩的空间在于裁剪 stdlib(去掉未用模块)与按需扩展,但收益有限且增加维护成本;对 PC 形态的专业工具而言,40 MB 属于正常水位。
Q12:ArbTS/ArkUI 有哪些「静默失效」的坑?
最典型的是属性链:.width()/.height() 读普通 @State 变量在运行时不更新(要用三元字面量或预计算字段);属性链里调用私有方法会被静默忽略;Span 的 backgroundColor 无效要改 textBackgroundStyle。本文的 Canvas 尺寸全部在 onReady 里读画布实际值,就是为了绕开这一类问题。
Q13:现在这个「3D」是真 3D 视口吗?
不是,它是等轴测线框视图:用纯 Canvas 2D 数学把 XB 盒体投影到斜视角,按深度排序画三个可见面与 12 条边;视角只能按 45° 步进旋转,没有透视、没有自由相机、没有 3D 拾取编辑,也不显示任意三角网格。真正的 3D 视口(XComponent + OpenGL ES、相机控制、网格导入)属于档③,尚未投入。但 FDS 场景几乎全是轴对齐盒子,等轴测线框在这种数据上信息量反而更高——没有遮挡歧义,墙板贯穿哪一层、火源贴在哪一面,一眼就能看清,而且零 GPU 依赖、包体不变、真机风险最低。
十、成果与路线图
当前成果(全部可在仓库复现):
| 项 | 值 |
|---|---|
| 内核剥离 | p0_core/bfds_core/,77 个 .py / 327 KB,可重放同步脚本 |
| 领域层验收 | 16/16(裸 CPython,零 Blender、零第三方包) |
| 内核协议验收 | 22/22(宿主模拟设备文件协议,含 P3 绘制接口 7 项) |
| HAP | entry-default-signed.hap 41.08 MB(runtime zip 8.78 MB / 808 条目,61 个原生 .so) |
| 画布能力 | 2D 三投影面 + 拖拽生成 OBST/VENT(MESH 单元对齐)、3D 等轴测线框(8 方位)、缩放 0.3×–5× 与拖拽平移 |
| 真机 | 冷启动握手、示例加载、属性编辑回写、校验警告、2D/3D 联动、绘制生成、缩放平移、另存为全部走通(八张截图) |
| 工程红线 | 零权限、无 eval、无子进程执行、凭据不入库 |
P3 已完成(P3-a):画布拖拽生成 OBST/VENT 并自动对齐 MESH 单元边界、选中 namelist 删除、XY/XZ/YZ 三投影面、3D 等轴测线框与 8 方位旋转、缩放平移、单行循环态工具栏。
后续计划:&SURF 材料库与参数语义提示;自由相机的真 3D 视口(XComponent + OpenGL ES,含 3D 拾取编辑);任意网格导入与 GEOM 转换;跟随上游 7.2.x 迭代(跑 sync_from_upstream.py + 全量回归即可);探索把规范导出的 .fds 交给远程 FDS 求解并回读结果的协同形态。
写在最后:这次移植最核心的经验只有一句——先花两天证明「领域层能脱离宿主运行」,再动手写一行业务代码。P0 那 16 项测试拦住的不只是技术风险,更是「要不要把 1.5 万行 Python 翻成 ArkTS」这种一旦走错就废掉整个项目的方向性错误。剩下的,就是把已验证的链路一层层搬上真机,每一步都留验收。
*
更多推荐




所有评论(0)