质码工坊上架:把 QualCoder 搬上鸿蒙 PC —— 内嵌 CPython 3.12 的定性分析工具移植技术全记录
欢迎加入开源鸿蒙 PC 社区:https://harmonypc.csdn.net/
欢迎在 PC 社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper
适配开源地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_QualCoder
一款面向社会科学研究者的定性资料分析工具,怎么在鸿蒙 PC 上从零跑起来并走进应用市场?本文不写"上架流程点哪里",而是把上架背后真正决定成败的技术链路拆开:161 个 Python 模块的 PyQt6 上游如何切成两层、交叉编译的 CPython 3.12 怎样静态链进 HAP、HarmonyOS 链接器命名空间为什么禁止
dlopen沙箱里的.so、ArkTS 与 Python 之间怎样约定一份不会崩的 JSON 契约,以及编码高亮在 ArkUI 里踩过的三个静默坑。文中代码全部摘自已开源的真实工程,截图来自应用市场详情页、宣传图与鸿蒙 PC 真机实拍。

图 1:应用市场详情页实拍。搜索「质码」即可命中,版本 1.2.0 (1020000),分类「工具」,年龄分级 3+,2026/11/4 更新,评分 5.0、安装 <1 万次。三张预览图分别对应「拖选即编码」「打开即用」「源自开源 QualCoder」,应用描述面向访谈稿、田野笔记、开放式问卷等非结构化材料的研究者。

一、上架成果:应用市场里的「质码工坊」
质码工坊(包名 org.qualcoder.native,v1.2.0)是一款面向 HarmonyOS PC(2in1 / 平板)的**定性资料分析(QDA, Qualitative Data Analysis)**工具,移植自学术界广泛使用的开源软件 QualCoder 4.0(LGPL-3.0)。它服务的场景很具体:研究者手上有几十份访谈转录、田野笔记、开放式问卷,需要建立代码本(codes),在原文上框选句子贴上代码,再回读、统计、复盘。
打开应用是这样一个三栏工作台:

图 2:应用介绍图「打开即用,零配置启动」。左侧项目面板负责载入资料与创建项目,中间是编码工作区,右侧是代码本与编码记录。界面完全由 ArkUI 声明式组件构成,没有 WebView、没有 Qt、没有嵌入网页。
上架版本已经跑通的核心能力:
| 能力 | 说明 |
|---|---|
| 首启自动部署内嵌 CPython 运行时 | rawfile zip 解压到应用沙箱,marker 幂等,二次启动秒进 |
| 内核自检 | 启动时执行 runTest(),日志出现 MVP_CORE_SUCCESS |
| 项目与资料 | 沙箱内创建 QualCoder 兼容项目,DocumentViewPicker 零权限载入 .txt/.md/.csv/.html/.htm/.tex/.rtf |
| 代码本 CRUD | 新建 / 删除代码,删除时级联清除其全部编码,实时计数 |
| 区间编码 | 拖选一段文字 → 选择或输入代码 → 落到 (code, source, pos0, pos1) 四元组 |
| 高亮回读 | 已编码区间按代码着色的彩色高亮,点击查看所属代码 |
| 撤回与持久化 | 单条撤回、清空当前资料编码,杀进程重启后数据完整恢复 |
| 零权限 | requestPermissions: [],不申请任何敏感 ACL,无网络访问 |
这篇文章的重点是这些能力是怎么实现的。上架只是结果,下面从路线选型开始,逐层把代码摊开。
二、路线选型:T0 / T1 / T2,为什么是「T1 内核 + T0 外壳」

图 3:应用介绍图「源自开源 QualCoder 的鸿蒙原生适配」。一句话概括技术身份:ArkTS 原生界面 + 内嵌 CPython 3.12 引擎。
2.1 三级框架
鸿蒙 PC 生态通常按迁移深度把应用分成三级:
| 等级 | 名称 | 迁移方式 | 典型特征 |
|---|---|---|---|
| T2 | 兼容运行级 | 不改源码,经兼容容器 / 翻译层运行原程序 | 依赖宿主兼容环境,性能损耗大,系统窗口 / 输入法 / 文件授权难融入 |
| T1 | 引擎迁移级 | 用嵌入式运行时(Python / Electron / Qt / Web / JSVM)承载原核心,仅适配壳层 | 携带数十至数百 MB 运行时,存在跨语言边界 |
| T0 | 原生适配级 | 业务逻辑以 ArkTS / ArkUI 原生移植 | 单进程、零桥接、原生窗口与输入、权限最小化、包体小 |
三条路线没有绝对对错。真正要做的是按软件形态选。
2.2 上游 QualCoder 的形态
_upstream/QualCoder/src/qualcoder/ 下有 161 个 Python 模块,依赖 PyQt6 ≥ 6.5、pymupdf、Pillow、python-vlc、EbookLib、pdfminer.six、pandas、matplotlib、plotly、networkx、langchain、faiss-cpu、transformers……这些包在 aarch64-ohos 上既没有轮子,PyQt6 更没有平台插件。
但拆开看,形态是「厚依赖、重数据模型、薄算法、重 GUI」:
- 最值钱的资产不是算法,是数据契约:
.qda的 SQLite 表结构、"代码贴到资料[pos0, pos1)区间"的语义、资料登记与去重规则; - 界面可以完全重写:桌面版 GUI 是 161 个模块里最大的一块,鸿蒙版只要三栏;
- 调用特征是低频 IO:一次"贴代码"是几毫秒的字典与文件操作,跨语言开销可忽略。
于是分层就定了:

T1 内核 + T0 外壳:领域层复用上游数据模型,界面层 100% ArkUI,全程不落 T2 容器。
2.3 一个有意思的对照
同一工作区的光学设计项目 ohos_Optiland 曾走过几乎相同的 T1 路线(静态链 CPython + NAPI 桥 + 首启解压运行时),最终却放弃 T1 转向纯 T0,理由是首启解压约 1 分钟、单次操作 5–10 秒,而"改一个曲率半径立刻看光路"是产品生命线。
本项目保留了 T1 且跑通,差别只在交互频率与单次计算量:
| 维度 | 光学设计(放弃 T1) | 质码工坊(保留 T1) |
|---|---|---|
| 核心操作 | 每次编辑触发全量三视场光线追迹 | 一次点击写一条编码记录 |
| 调用频率 | 每帧 / 每敲一个字符 | 每几秒一次人工点击 |
| 人类感知阈值 | 几十毫秒即卡顿 | 几百毫秒无感 |
| 结论 | 跨语言开销打断编辑循环 → 必须 T0 | 开销可忽略 → T1 换数据模型保真,划算 |
所以领域层只保留 9 个函数、341 行:T1 的价值在于复用契约,不在于搬运代码量。搬得越少,运行时越轻,越不容易掉进 T1 的性能陷阱。
代码说明:下文代码块均摘自已开源工程的真实源码。配置类片段为节选(只保留与讨论相关的字段),
其余为便于阅读做了换行与排版合并,未改动任何标识符、字段名或逻辑;完整源码以仓库为准。
三、第一块硬骨头:把 CPython 3.12 静态链进 HAP
3.1 CMake:两行链接参数决定生死
entry/src/main/cpp/CMakeLists.txt 全文只有 31 行,但每一行都不能省:
cmake_minimum_required(VERSION 3.4.1)
project(QualCoderNative C CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# Real cross-compiled CPython 3.12 (static) for OHOS aarch64
set(PYTHON_INC "${CMAKE_CURRENT_SOURCE_DIR}/python_inc")
set(PYTHON_LIB "${CMAKE_CURRENT_SOURCE_DIR}/prebuilt/libpython3.12.a")
add_library(qualcoder_native SHARED qualcoder_napi.cpp)
# Extension modules (lib-dynload .so files) are dlopened at runtime and
# resolve libpython symbols from this host .so. --export-dynamic pushes our
# dynsym entries into the global namespace so RTLD_LOCAL dlopen of those
# extensions can relocate against them.
target_link_options(qualcoder_native PRIVATE -Wl,--export-dynamic)
target_include_directories(qualcoder_native PRIVATE "${PYTHON_INC}")
# whole-archive: pull full interpreter from static archive into the NAPI .so
target_link_libraries(qualcoder_native PUBLIC
-Wl,--whole-archive
"${PYTHON_LIB}"
-Wl,--no-whole-archive
libace_napi.z.so
libhilog_ndk.z.so
libc++.a
libm.a
libdl.a
)
两个关键点:
-Wl,--whole-archive … -Wl,--no-whole-archive:静态库默认只拉入被直接引用的目标文件。不加 whole-archive,libpython3.12.a(33.32 MB)里大量内建模块符号会在解释器启动后缺失。-Wl,--export-dynamic:把宿主.so的动态符号推进全局符号域,让后续被dlopen的 C 扩展能重定位到宿主里的PyModuleDef_Init等 libpython 符号。
对应 entry/build-profile.json5 打开 native 编译:
{
"apiType": "stageMode",
"buildOption": {
"externalNativeOptions": {
"path": "./src/main/cpp/CMakeLists.txt",
"arguments": "",
"abiFilters": ["arm64-v8a"],
"cppFlags": "-std=c++17"
},
"nativeLib": { "debugSymbol": { "strip": false } }
},
"targets": [{ "name": "default", "runtimeOS": "HarmonyOS" }]
}
strip: false 是刻意的:真机抓 native 栈时保留符号,38.78 MB 的包体里这部分很值。
3.2 el2 目录禁止 dlopen:扩展必须随 HAP libs 分发
HarmonyOS 的链接器命名空间禁止对应用沙箱 filesDir(el2)里解压出来的 .so 执行 dlopen。所以 lib-dynload/*.so 不能留在 zip 里,必须随 HAP 的 entry/libs/arm64-v8a/(el1 bundle 路径)分发。
tools/extract_dynload.py 负责抽取,并留了一道很硬的安全网:
DEFAULT_SOURCE = ROOT / "ohos_hap/entry/src/main/resources/rawfile/ohos_python_stdlib.zip"
DEFAULT_TARGET = ROOT / "ohos_hap/entry/libs/arm64-v8a"
DYNLOAD_PREFIX = "ohos_python/lib/python3.12/lib-dynload/"
def extract(source: Path, target: Path) -> list[str]:
if not source.is_file():
raise SystemExit(f"runtime zip not found: {source}")
target.mkdir(parents=True, exist_ok=True)
# wipe stale non-so artifacts (safety net)
for stale in target.iterdir():
if not stale.name.endswith(".so"):
raise SystemExit(
f"unexpected non-so file in libs dir: {stale} — remove it manually"
)
written: list[str] = []
with zipfile.ZipFile(source) as z:
for info in z.infolist():
if not (info.filename.startswith(DYNLOAD_PREFIX) and info.filename.endswith(".so")):
continue
name = info.filename.rsplit("/", 1)[-1]
dest = target / name
dest.write_bytes(z.read(info.filename))
dest.chmod(0o755)
written.append(name)
if not written:
raise SystemExit("no lib-dynload .so found in the runtime zip")
return written
那个 for stale in target.iterdir() 循环来自一次惨痛教训:build-profile.json5 开了 collectAllLibs: true,该目录里任何非 .so 文件都会被打包进 HAP。早期有扫描产物落在里面,包体凭空涨了 100 MB。现在脚本遇到杂文件直接拒绝执行。
3.3 DT_NEEDED 补丁:让扩展找到 libpython
即便宿主提升到 RTLD_GLOBAL,NAPI 框架以 RTLD_LOCAL 加载的扩展仍可能报 Error relocating ... PyModuleDef_Init: symbol not found。tools/patch_dynload_needed.py 用 lief 把依赖显式写进 ELF:
HOST_SONAME = "libqualcoder_native.so"
def patch_dir(target: Path) -> int:
patched = 0
for so_path in sorted(target.glob("*.so")):
if so_path.name == HOST_SONAME:
continue
bin_ = lief.parse(str(so_path))
if bin_ is None:
raise SystemExit(f"cannot parse ELF: {so_path}")
needed = [e.name for e in bin_.dynamic_entries
if e.tag == lief.ELF.DynamicEntry.TAG.NEEDED]
if HOST_SONAME in needed:
continue # 幂等:已打过就跳过
if not bin_.add_library(HOST_SONAME):
raise SystemExit(f"failed to add DT_NEEDED to {so_path}")
bin_.write(str(so_path))
patched += 1
return patched
54 个扩展全部打上 DT_NEEDED libqualcoder_native.so 后,import math / import zlib / import _json 才真正可用。
3.4 运行时 zip 的重建
tools/build_runtime.py 从 Thonny 版 CPython 3.12 基线 zip 重建发行包,剔除 vendored 编辑器与 lib-dynload,注入业务包:
PACKAGE_PREFIX = "ohos_python/lib/python3.12/site-packages/qualcoder/"
DROP_PREFIXES = (
"ohos_python/lib/python3.12/site-packages/thonny/",
PACKAGE_PREFIX,
)
DYNLOAD_PREFIX = "ohos_python/lib/python3.12/lib-dynload/"
PACKAGE_FILES = ("__init__.py", "ohos_mvp.py")
def build(source: Path, package: Path, target: Path) -> dict[str, int]:
dropped = 0
copied = 0
target.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(source) as src, zipfile.ZipFile(
target, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6
) as dst:
for info in src.infolist():
if any(info.filename.startswith(prefix) for prefix in DROP_PREFIXES):
dropped += 1
continue
if info.filename.startswith(DYNLOAD_PREFIX) and info.filename.endswith(".so"):
dropped += 1
continue
data = b"" if info.is_dir() else src.read(info.filename)
dst.writestr(info, data)
copied += 1
dst.writestr(PACKAGE_PREFIX, b"")
for name in PACKAGE_FILES:
dst.writestr(PACKAGE_PREFIX + name, (package / name).read_bytes())
dst.writestr(PACKAGE_PREFIX + "QUALCODER_OHOS_MVP",
b"qualcoder-ohos-mvp 1.0.0\n")
return {"kept": copied, "dropped": dropped, "size": target.stat().st_size}
产物 ohos_python_stdlib.zip 4.39 MB,包含 CPython 3.12 标准库与 site-packages/qualcoder/{__init__,ohos_mvp}.py。注意 ohos_hap 内没有 .py 副本:改业务必须改上游那份再重打包。
四、NAPI 桥:进程内解释器与文件捕获 stdout
4.1 只暴露 4 个方法
cpp/types/libqualcoder_native/index.d.ts:
interface QualCoderNativeApi {
getBuildInfo(): string;
setPythonHome(home: string): string;
runTest(): string;
runCode(code: string): string;
}
declare const qualcoderNative: QualCoderNativeApi;
export default qualcoderNative;
C++ 侧注册(qualcoder_napi.cpp):
static napi_value Export(napi_env env, napi_value exports) {
napi_property_descriptor desc[] = {
{"getBuildInfo", nullptr, GetBuildInfo, nullptr, nullptr, nullptr, napi_default, nullptr},
{"setPythonHome", nullptr, SetPythonHome, nullptr, nullptr, nullptr, napi_default, nullptr},
{"runTest", nullptr, RunTest, nullptr, nullptr, nullptr, napi_default, nullptr},
{"runCode", nullptr, RunCode, nullptr, nullptr, nullptr, napi_default, nullptr},
};
napi_define_properties(env, exports, sizeof(desc) / sizeof(desc[0]), desc);
return exports;
}
EXTERN_C_START
static napi_module demoModule = {
.nm_version = 1,
.nm_flags = 0,
.nm_filename = nullptr,
.nm_register_func = Export,
.nm_modname = "qualcoder_native",
.nm_priv = ((void *)0),
.reserved = {0}
};
EXTERN_C_END
extern "C" __attribute__((constructor)) void RegisterModule(void) {
napi_module_register(&demoModule);
}
全部业务都走 runCode(字符串) 这一个入口——实现最简、不必为每个函数写 NAPI 包装。代价是后面要讲的注入面,属于明确登记的技术债。
4.2 单例状态与目录诊断
static std::mutex g_pyMu;
static bool g_pyInited = false;
static std::string g_pyHome;
static std::string ProbeHome(const std::string &home) {
const std::string lib = home + "/lib/python3.12";
const std::string osPy = lib + "/os.py";
std::string msg = "home_access=" + std::string(PathAccessOk(home) ? "y" : "n");
msg += " home_dir=" + std::string(DirExists(home) ? "y" : "n");
msg += " lib_dir=" + std::string(DirExists(lib) ? "y" : "n");
msg += " os.py=" + std::string(FileExists(osPy) ? "y" : "n");
if (DirExists(home)) {
msg += " home_ls=[" + DirListing(home) + "]";
}
return msg;
}
ProbeHome() 把「解压到底成没成功」变成一行可直接读的字符串回传给 ArkTS。真机排障时,home_dir=y lib_dir=y os.py=y 与一串 home_ls=[...] 比任何日志都直观。
4.3 EnsurePyInitLocked():初始化解释器
这是整个桥的核心,56 行:
static std::string EnsurePyInitLocked() {
if (g_pyInited) {
return "already";
}
if (g_pyHome.empty()) {
return "ERR: setPythonHome first";
}
const std::string lib = g_pyHome + "/lib/python3.12";
const std::string osPy = lib + "/os.py";
if (!DirExists(lib) || !FileExists(osPy)) {
return std::string("ERR: missing stdlib under ") + g_pyHome + " | " + ProbeHome(g_pyHome);
}
const std::string site = lib + "/site-packages";
// C extensions live next to this .so in the HAP libs dir (el1 bundle
// path). The linker forbids dlopen on el2 files, so lib-dynload must
// resolve here. Derive the libs dir from our own module path.
std::string dynload = g_pyHome + "/lib/python3.12/lib-dynload";
Dl_info info {};
if (dladdr(reinterpret_cast<void *>(&EnsurePyInitLocked), &info) && info.dli_fname) {
std::string self(info.dli_fname);
// Promote ourselves into the global scope (RTLD_NOLOAD on an
// already-loaded object just re-flags it). Without this, extension
// modules dlopened by the import machinery cannot relocate
// libpython symbols against this host .so.
dlopen(self.c_str(), RTLD_LAZY | RTLD_GLOBAL | RTLD_NOLOAD);
const size_t slash = self.rfind('/');
if (slash != std::string::npos) {
const std::string dir = self.substr(0, slash);
if (FileExists(dir + "/math.cpython-312-aarch64-linux-ohos.so")) {
dynload = dir;
}
}
}
setenv("PYTHONHOME", g_pyHome.c_str(), 1);
setenv("PYTHONPATH", (lib + ":" + site + ":" + dynload).c_str(), 1);
setenv("PYTHONUNBUFFERED", "1", 1);
setenv("PYTHONDONTWRITEBYTECODE", "1", 1);
setenv("PYTHONNOUSERSITE", "1", 1);
wchar_t *wHome = Py_DecodeLocale(g_pyHome.c_str(), nullptr);
if (wHome != nullptr) {
Py_SetPythonHome(wHome);
}
Py_InitializeEx(0);
if (!Py_IsInitialized()) {
return "ERR: Py_InitializeEx failed";
}
const std::string inject =
"import sys\n"
"p = " + PyQuote(site) + "\n"
"if p not in sys.path: sys.path.insert(0, p)\n"
"d = " + PyQuote(dynload) + "\n"
"if d not in sys.path: sys.path.append(d)\n";
PyRun_SimpleString(inject.c_str());
g_pyInited = true;
return "ok";
}
四个动作缺一不可:
dladdr(&EnsurePyInitLocked, &info)拿自身.so路径,推出 HAP 的libs目录;用math.cpython-312-aarch64-linux-ohos.so是否存在当探针;dlopen(self, RTLD_LAZY | RTLD_GLOBAL | RTLD_NOLOAD)把已加载的宿主提升到全局符号域;PYTHONHOME/PYTHONPATH/PYTHONDONTWRITEBYTECODE等五个环境变量一次设齐;Py_SetPythonHome→Py_InitializeEx(0)→ 再用PyRun_SimpleString把site-packages插到sys.path[0]、把 dynload 追加到末尾。只设环境变量不注入sys.path,某些初始化顺序下import qualcoder仍会失败。
4.4 RunPythonCode():文件捕获而不是管道
static std::string RunPythonCode(const std::string &code, const char *phaseTag) {
std::lock_guard<std::mutex> lock(g_pyMu);
const std::string init = EnsurePyInitLocked();
if (init.rfind("ERR", 0) == 0) {
return init;
}
const size_t slash = g_pyHome.rfind('/');
const std::string filesDir = slash == std::string::npos ? g_pyHome : g_pyHome.substr(0, slash);
const std::string outFile = filesDir + "/py_capture_out.txt";
const std::string errFile = filesDir + "/py_capture_err.txt";
std::string indented;
size_t i = 0;
while (i <= code.size()) {
size_t j = code.find('\n', i);
if (j == std::string::npos) {
j = code.size();
}
indented += " ";
indented.append(code, i, j - i);
indented.push_back('\n');
if (j >= code.size()) {
break;
}
i = j + 1;
}
const std::string wrapped =
"import sys\n"
"class _Capture:\n"
" def __init__(self, path): self.f = open(path, 'w', encoding='utf-8')\n"
" def write(self, value): self.f.write(value)\n"
" def flush(self): self.f.flush()\n"
" def close(self): self.f.close()\n"
"_out = _Capture(" + PyQuote(outFile) + ")\n"
"_err = _Capture(" + PyQuote(errFile) + ")\n"
"sys.stdout = _out\n"
"sys.stderr = _err\n"
"try:\n" + indented +
"except Exception:\n"
" import traceback\n"
" traceback.print_exc()\n"
"finally:\n"
" _out.flush(); _err.flush(); _out.close(); _err.close()\n";
const int rc = PyRun_SimpleString(wrapped.c_str());
std::string report = std::string("[") + phaseTag + "] real CPython 3.12\n";
report += "[" + std::string(phaseTag) + "] PyRun_SimpleString rc=" + std::to_string(rc) + "\n";
const std::string out = ReadFile(outFile);
const std::string err = ReadFile(errFile);
if (!out.empty()) {
report += "--- stdout ---\n" + out;
if (out.back() != '\n') report += "\n";
}
if (!err.empty()) {
report += "--- stderr ---\n" + err;
if (err.back() != '\n') report += "\n";
}
return report;
}
要点:
- 传入代码逐行缩进 4 空格后嵌入
try/except/finally模板,业务代码不需要自己包异常; _Capture用文件而非管道:OHOS 社区移植的定制 libpython 在 fork 子进程里存在Py_BytesMain空跑、io 层写盘静默丢失等问题,文件方案简单、可读、可留证;finally里必须 flush + close,否则 C++ 读回的是空内容或截断内容;- 报告格式固定为
[MVP] … / --- stdout --- / --- stderr ---,ArkTS 侧按分隔符解析。
4.5 内置自检
static napi_value RunTest(napi_env env, napi_callback_info info) {
(void)info;
const char *code =
"import sys, tempfile\n"
"from pathlib import Path\n"
"from qualcoder.ohos_mvp import create_project, import_text, summary\n"
"root = Path(tempfile.gettempdir()) / 'qualcoder_mvp_test.qda'\n"
"if root.exists():\n"
" import shutil\n"
" shutil.rmtree(root)\n"
"create_project(str(root), 'MVP Test')\n"
"import_text(str(root), 'interview.txt', 'HarmonyOS QualCoder MVP\\n')\n"
"result = summary(str(root))\n"
"print('QualCoder core ready')\n"
"print('python', sys.version.split()[0])\n"
"print('project', result['name'])\n"
"print('sources', result['sources'])\n"
"print('storage', (root / 'data.qda').is_file() or (root / 'qualcoder_mvp.json').is_file())\n"
"assert result['sources'] == 1\n"
"assert (root / 'data.qda').is_file() or (root / 'qualcoder_mvp.json').is_file()\n"
"print('MVP_CORE_SUCCESS')\n";
std::string report = RunPythonCode(code, "MVP");
const bool ok = report.find("MVP_CORE_SUCCESS") != std::string::npos &&
report.find("PyRun_SimpleString rc=0") != std::string::npos;
report += ok
? "[MVP] SUCCESS: QualCoder SQLite project and text import core passed.\n"
: "[MVP] FAIL: QualCoder core self-check did not pass.\n";
return MakeStr(env, report);
}
自检建临时项目、导入文本、断言计数,只有标记与 rc=0 同时满足才判成功。图 4 里底部日志的 QualCoder core ready 就是它打出来的。
4.6 为什么不 fork、不 exec、不用管道
工程里没有 fork / exec / popen / system() / socket / pipe IPC。HarmonyOS 沙箱禁止 exec 外部二进制,filesDir 带 noexec 语义;定制 libpython 在 fork 子进程里还会遇到 Py_BytesMain 返回 0 却不执行脚本、以及 open() 写盘静默丢失(只有 os.write(1, …) 直通系统调用可靠)。
所以选了最朴素的一条:解释器就是应用进程里的一段代码,Py_InitializeEx 一次常驻,所有调用 PyRun_SimpleString 同步完成,由 g_pyMu 保证串行。代价是 Python 调用串行、且在 UI 线程执行;收益是没有进程生命周期、没有 IPC 序列化、没有半路丢输出。
五、ArkTS 工作台:首启部署、零权限读写与编码闭环
5.1 prepareRuntime():marker 幂等
Index.ets 在 aboutToAppear() 里取 filesDir,拼出 pythonHome = filesDir/ohos_python,然后:
const RUNTIME_ZIP = 'ohos_python_stdlib.zip';
const RUNTIME_MARKER = 'READY_QUALCODER_P1_0_0_2';
private async prepareRuntime(): Promise<void> {
if (this.busy || !this.context) {
return;
}
this.busy = true;
this.status = '准备 Python 运行时…';
try {
const marker = `${this.pythonHome}/.${RUNTIME_MARKER}`;
const osPy = `${this.pythonHome}/lib/python3.12/os.py`;
if (!openable(osPy) || !exists(marker) || readText(marker).indexOf(RUNTIME_MARKER) < 0) {
mkdirp(this.pythonHome);
const zipPath = `${this.filesDir}/${RUNTIME_ZIP}`;
const content = await this.context.resourceManager.getRawFileContent(RUNTIME_ZIP);
const zip = fileIo.openSync(zipPath,
fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.TRUNC);
try {
fileIo.writeSync(zip.fd, content.buffer as ArrayBuffer);
} finally {
fileIo.closeSync(zip);
}
await zlib.decompressFile(zipPath, this.filesDir);
if (!openable(osPy)) {
throw new Error(`运行时缺少 ${osPy}`);
}
writeText(marker, `${RUNTIME_MARKER}\n`);
this.log('运行时已解压');
} else {
this.log('复用已部署运行时');
}
const homeResult = qualcoderNative.setPythonHome(this.pythonHome);
if (homeResult.indexOf('ERR') >= 0 || homeResult.indexOf('os.py=n') >= 0) {
throw new Error(homeResult);
}
this.log(`home ${homeResult}`);
this.runtimeReady = true;
this.status = '运行时就绪';
this.log('CPython 3.12 已连接');
this.ensureProject();
this.refreshCodes();
this.runSelfCheck();
} catch (e) {
this.status = `运行时失败:${(e as Error).message ?? String(e)}`;
this.log(this.status);
} finally {
this.busy = false;
}
}
判据是「os.py 可读 且 marker 内容匹配」双条件,避免解压中断留下半截运行时。衍生坑:改了 ohos_mvp.py 并重跑 build_runtime.py 后,如果忘了递增 RUNTIME_MARKER,应用会认为运行时已是最新、继续用沙箱里的旧 Python 文件,表现为「代码改了完全没生效」。这个坑足以浪费半天。
5.2 三栏布局
build() {
Stack({ alignContent: Alignment.Center }) {
Column() {
Row() {
Text('质码工坊').fontSize(20).fontWeight(FontWeight.Bold).fontColor(ACCENT)
Text(` ${this.status}`).fontSize(13)
.fontColor(this.status.includes('失败') ? BAD : MUTED)
.layoutWeight(1).margin({ left: 12 })
Text('关于').fontSize(14).fontColor(ACCENT).padding(8)
.onClick(() => this.showAbout = true)
}
.height(48).width('100%').padding({ left: 14, right: 10 })
.backgroundColor(FRAME_BG)
.border({ width: { bottom: 1 }, color: FRAME_BORDER })
Row() {
this.ProjectPanel() // .width('24%')
this.CodingPanel() // .layoutWeight(1)
this.CodePanel() // .width('30%')
}.layoutWeight(1).width('100%')
Row() {
Text('日志').fontSize(13).fontColor(MUTED).margin({ left: 10, right: 10 })
Text(this.logLines.slice(-1)[0] ?? '').fontSize(12).fontColor(MUTED)
.maxLines(1).layoutWeight(1)
}.height(28).width('100%').backgroundColor('#eceae5')
}.width('100%').height('100%')
if (this.showAbout) {
this.AboutDialog()
}
}
.width('100%').height('100%').backgroundColor('#efeee9')
}
48px 顶栏 + 三栏 + 28px 日志栏。日志栏只显示最后一条,但 log() 保留最近 120 条——真机排障时 Python 回包与 traceback 都在这里。
5.3 零权限读取外部文件
module.json5 里 requestPermissions: [],外部文本靠系统选择器用户一次性授权:
private async pickText(): Promise<void> {
if (!this.context || !this.runtimeReady || this.busy) {
return;
}
this.busy = true;
try {
const pickerView = new picker.DocumentViewPicker(this.context);
const options = new picker.DocumentSelectOptions();
options.fileSuffixFilters = ['.txt', '.md', '.csv', '.html', '.htm', '.tex', '.rtf'];
const uris = await pickerView.select(options);
if (!uris || uris.length === 0) {
return;
}
const uri = uris[0];
const source = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY);
try {
const stat = fileIo.statSync(source.fd);
const buffer = new ArrayBuffer(Number(stat.size));
const count = fileIo.readSync(source.fd, buffer);
this.selectedText = decodeText(new Uint8Array(buffer, 0, count));
this.selectedName = nameFromUri(uri);
this.status = `已载入:${this.selectedName}`;
this.log(`资料载入:${this.selectedName}(${count} bytes)`);
} finally {
fileIo.closeSync(source);
}
} catch (e) {
this.status = `载入失败:${(e as Error).message ?? String(e)}`;
this.log(this.status);
} finally {
this.busy = false;
}
}
function decodeText(bytes: Uint8Array): string {
const text = util.TextDecoder.create('utf-8').decodeToString(bytes);
return text.indexOf('\uFFFD') >= 0
? util.TextDecoder.create('gb18030').decodeToString(bytes)
: text;
}
两个 API 细节:fileIo.openSync() 返回的是 File 对象,读写必须传 .fd;util.TextDecoder.decodeToString 需要 Uint8Array。中文编码用 UTF-8 优先、检测到 \uFFFD 回退 GB18030 的双判据。
注意载入 ≠ 入库:pickText() 只把文本放进内存,必须再点「导入当前资料」才调 import_text 写进项目。图 5 里 sources: 0 正是"载入示例但还没导入"的状态,这个设计让浏览与入库可分离。
5.4 一次「贴代码」的完整调用链
private codeSelection(): void {
const selText = this.selectedText.substring(this.selStart, this.selEnd);
if (this.selStart < 0 || this.selEnd <= this.selStart || selText.length === 0) {
this.status = '请先在资料中选中一段文字';
return;
}
const code = this.pickedCode || this.newCode.trim();
if (code.length === 0) {
this.status = '请先选择或输入代码';
return;
}
const path = this.projectPath();
mkdirp(path);
const result = this.runJson(
`import json\nfrom qualcoder.ohos_mvp import code_selection\n` +
`print(json.dumps(code_selection(${this.pyQuote(path)}, ${this.pyQuote(code)}, ` +
`${this.pyQuote(this.selectedName || 'document.txt')}, ${this.pyQuote(selText)}, ` +
`${this.selStart}, ${this.selEnd}), ensure_ascii=False))`
);
try {
const parsed = JSON.parse(result) as CodingResult;
if (parsed.ok === true) {
if (parsed.summary) {
this.project = parsed.summary;
}
this.refreshCodes();
this.viewMode = true;
this.selStart = -1;
this.selEnd = -1;
this.status = `已编码「${code}」:${selText.length > 20 ? `${selText.substring(0, 20)}…` : selText}`;
} else {
this.status = '编码失败';
}
} catch (e) {
this.status = '编码失败';
this.log(`code_selection 返回:${result}`);
this.log(`${(e as Error).message ?? String(e)}`);
}
}
private runJson(code: string): string {
const output = parseReport(qualcoderNative.runCode(code));
this.log(output);
const last = output.lastIndexOf('\n');
return last >= 0 ? output.substring(last + 1).trim() : output.trim();
}
function parseReport(report: string): string {
const marker = '--- stdout ---';
const error = '--- stderr ---';
const begin = report.indexOf(marker);
if (begin < 0) {
return report;
}
const from = begin + marker.length;
const end = report.indexOf(error, from);
return report.substring(from, end >= 0 ? end : report.length).trim();
}
private pyQuote(value: string): string {
return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n')}'`;
}
跨语言契约就三段:ArkTS 拼 Python 源码 → C++ 返回带分隔符的报告 → ArkTS 取 stdout 最后一行交给 JSON.parse。约定业务函数一律返回 dict 或 list,且领域层保持安静、只用返回值说话——任何多余的 print 都会污染最后一行。
5.5 高亮渲染:buildChunks + Span
interface TextChunk {
text: string;
code: string;
color: string;
bold: boolean;
}
const CODE_COLORS: string[] =
['#ffe08a', '#b3e0ff', '#c8f0c8', '#f8c8d8', '#e0d0f8', '#ffd8b0'];
function colorForCode(code: string): string {
let hash = 0;
for (let i = 0; i < code.length; i++) {
hash = (hash * 31 + code.charCodeAt(i)) % 997;
}
return CODE_COLORS[hash % CODE_COLORS.length];
}
function buildChunks(text: string, codings: CodingEntry[]): TextChunk[] {
const sorted = codings
.filter((item) => item.pos0 >= 0 && item.pos1 > item.pos0 && item.pos1 <= text.length)
.sort((a, b) => a.pos0 - b.pos0 || b.pos1 - a.pos1);
const chunks: TextChunk[] = [];
let cursor = 0;
for (const span of sorted) {
if (span.pos0 < cursor) {
continue; // overlaps a previous span, skip
}
if (span.pos0 > cursor) {
chunks.push({ text: text.substring(cursor, span.pos0),
code: '', color: '#00000000', bold: false });
}
chunks.push({ text: text.substring(span.pos0, span.pos1), code: span.code,
color: colorForCode(span.code), bold: true });
cursor = span.pos1;
}
if (cursor < text.length) {
chunks.push({ text: text.substring(cursor), code: '',
color: '#00000000', bold: false });
}
return chunks;
}
渲染侧把 Text 切成 Span 流:
Text() {
ForEach(buildChunks(this.selectedText, this.currentCodings),
(chunk: TextChunk, index?: number) => {
Span(chunk.text)
.fontWeight(chunk.bold ? FontWeight.Bold : FontWeight.Normal)
.textBackgroundStyle({ color: chunk.color, radius: 4 })
.onClick(() => {
if (chunk.code.length > 0) {
this.status = `此段已编码为「${chunk.code}」`;
}
})
},
(chunk: TextChunk, index?: number) => `${index}-${chunk.code}-${chunk.text.length}`)
}
.fontSize(14).fontColor(TEXT).lineHeight(24).textAlign(TextAlign.Start).width('100%')
5.6 三个真正会杀进程的 ArkUI 坑
坑一:dict 直接喂给 ForEach。add_code / delete_code 返回 {ok, name, codes},早期整个赋给 @State codeEntries: CodeEntry[],ArkUI 抛 data source dose not have forEach function(103806)并终止应用。修法是取字段 + 类型校验:
const parsed = JSON.parse(result) as CodeOpResult;
const codes = parsed?.codes;
if (Array.isArray(codes)) {
this.codeEntries = codes;
this.newCode = '';
this.status = `已添加代码:${value}`;
} else {
this.status = '添加代码失败';
this.log(`add_code 返回:${result}`);
}
所有 JSON.parse 结果赋给数组状态的地方都要加这层防护。
坑二:Span 的 backgroundColor 与属性链里的函数调用会被静默忽略。Span 不是普通组件,背景必须用 .textBackgroundStyle({color, radius});属性链里调 colorForCode(chunk.code) 这类方法也不生效——所以颜色和 bold 必须像上面那样在 buildChunks() 里预计算进 TextChunk 字段,属性链只读字段。
坑三:TextArea 失焦清空选区。点「编码选中文字」时失焦会触发一次 onTextSelectionChange(start == end),无脑写入就把刚拖好的选区覆盖成空:
TextArea({ text: this.selectedText })
.onTextSelectionChange((start: number, end: number) => {
if (end > start) { // 只在非空选区时更新
this.selStart = start;
this.selEnd = end;
}
})
同类经验:.width()/.height() 读普通 @State 变量运行时可能不更新,三元表达式返回字面量百分比才可靠——本工程三栏固定用 '24%' / '30%' 字面量,正好避开。

图 4:鸿蒙 PC 真机桌面实拍。窗口标题「质码工坊(from QualCoder)」,三栏工作台完整渲染,左栏显示 MVP 项目 与沙箱路径 /data/storage/el2/base/haps/entry/files/QualCoderProjects/mvp.qda,底部日志栏打出 QualCoder core ready——runTest() 自检通过。
六、领域层:QualCoder 数据模型与双通道降级
6.1 上游兼容的 5 张表
ohos_mvp.py 保留的是上游真正的资产——数据契约:
try:
import sqlite3
except Exception:
sqlite3 = None
SCHEMA = (
"CREATE TABLE IF NOT EXISTS project (databaseversion TEXT, date TEXT, memo TEXT, about TEXT, "
"bookmarkfile INTEGER, bookmarkpos INTEGER, codername TEXT, recently_used_codes TEXT, "
"avbookmarkfile INTEGER, avbookmarkmsec INTEGER, avbookmarktextpos INTEGER)",
"CREATE TABLE IF NOT EXISTS source (id INTEGER PRIMARY KEY, name TEXT UNIQUE, fulltext TEXT, "
"mediapath TEXT, memo TEXT, owner TEXT, date TEXT, av_text_id INTEGER, risid INTEGER)",
"CREATE TABLE IF NOT EXISTS code_cat (catid INTEGER PRIMARY KEY, name TEXT UNIQUE, owner TEXT, "
"date TEXT, memo TEXT, supercatid INTEGER)",
"CREATE TABLE IF NOT EXISTS code_name (cid INTEGER PRIMARY KEY, name TEXT UNIQUE, memo TEXT, "
"catid INTEGER, owner TEXT, date TEXT, color TEXT, supercid INTEGER)",
"CREATE TABLE IF NOT EXISTS code_text (ctid INTEGER PRIMARY KEY, cid INTEGER, fid INTEGER, "
"seltext TEXT, pos0 INTEGER, pos1 INTEGER, owner TEXT, date TEXT, memo TEXT, avid INTEGER, "
"important INTEGER, UNIQUE(cid, fid, pos0, pos1, owner))",
)
project、source、code_cat、code_name、code_text 五张表就是桌面版 .qda 的核心结构;code_text 上的 UNIQUE(cid, fid, pos0, pos1, owner) 保证同一段文字同一代码不会被重复编码。领域层总共 9 个公开函数,全部返回 dict / list 以便 JSON 序列化。
6.2 双通道自动降级
54 个 C 扩展里有 zlib、_json、_datetime、_socket、unicodedata,唯独没有 _sqlite3(也没有 _ssl)。于是设计成可插拔存储层:
def _manifest(path: Path) -> Path:
return path / "qualcoder_mvp.json"
def _read_manifest(path: Path) -> dict[str, Any]:
target = _manifest(path)
if not target.is_file():
return {"name": path.stem, "sources": [], "codes": [], "codings": []}
try:
return json.loads(target.read_text(encoding="utf-8"))
except Exception:
return {"name": path.stem, "sources": [], "codes": [], "codings": []}
def _create_sqlite_project(path: Path, name: str) -> bool:
if sqlite3 is None:
return False
try:
with sqlite3.connect(path / "data.qda") as conn:
for statement in SCHEMA:
conn.execute(statement)
if conn.execute("SELECT COUNT(*) FROM project").fetchone()[0] == 0:
now = _datetime.datetime.now().astimezone().isoformat(timespec="seconds")
conn.execute("INSERT INTO project VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
("ohos-mvp", now, "", name, 0, 0, "", "", None, None, None))
conn.commit()
return True
except Exception:
return False
def create_project(project_path: str, project_name: str = "") -> dict[str, Any]:
path = Path(project_path)
path.mkdir(parents=True, exist_ok=True)
for folder in ("documents", "images", "audio", "video"):
(path / folder).mkdir(exist_ok=True)
data = _read_manifest(path)
name = project_name or data.get("name") or path.stem
data["name"] = name
data["storage"] = "sqlite" if _create_sqlite_project(path, name) else "manifest"
_write_manifest(path, data)
return summary(str(path))
def _sqlite_ok(path: Path) -> bool:
return (
sqlite3 is not None
and (path / "data.qda").is_file()
and _read_manifest(path).get("storage") == "sqlite"
)
所有读写先过 _sqlite_ok()。当前真机 storage == "manifest",走 qualcoder_mvp.json;补齐 _sqlite3 扩展后,业务代码一行不改就能切回真正的 data.qda,进而与桌面版项目文件互通。图 5 底部日志里 "storage":"manifest" 就是这条降级的现场证据。
6.3 区间编码:code_selection
def code_selection(
project_path: str, code: str, source: str, seltext: str, pos0: int, pos1: int
) -> dict[str, Any]:
"""Attach a code to a text span [pos0, pos1) of a source document."""
code = (code or "").strip()
if not code:
raise ValueError("code must not be empty")
if pos0 < 0 or pos1 <= pos0:
raise ValueError(f"invalid text span: [{pos0}, {pos1})")
path = Path(project_path)
if not path.is_dir():
raise FileNotFoundError(f"not a QualCoder project: {path}")
now = _datetime.datetime.now().astimezone().isoformat(timespec="seconds")
if _sqlite_ok(path):
with sqlite3.connect(path / "data.qda") as conn:
code_row = conn.execute("SELECT cid FROM code_name WHERE name = ?", (code,)).fetchone()
if code_row is None:
conn.execute(
"INSERT INTO code_name (name, memo, catid, owner, date, color, supercid) "
"VALUES (?, '', NULL, ?, ?, '#3569a8', NULL)", (code, OWNER, now))
code_row = conn.execute(
"SELECT cid FROM code_name WHERE name = ?", (code,)).fetchone()
src_row = conn.execute("SELECT id FROM source WHERE name = ?", (source,)).fetchone()
if src_row is None:
raise FileNotFoundError(f"source not in project: {source}")
conn.execute(
"INSERT OR REPLACE INTO code_text "
"(cid, fid, seltext, pos0, pos1, owner, date, memo, avid, important) "
"VALUES (?, ?, ?, ?, ?, ?, ?, '', NULL, 0)",
(code_row[0], src_row[0], seltext, pos0, pos1, OWNER, now))
conn.commit()
data = _read_manifest(path)
codes = data.setdefault("codes", [])
if not any(item.get("name") == code for item in codes):
codes.append({"name": code, "memo": "", "date": now, "color": "#3569a8"})
codings = data.setdefault("codings", [])
codings[:] = [
item for item in codings
if not (item.get("code") == code and item.get("source") == source
and item.get("pos0") == pos0 and item.get("pos1") == pos1)
]
codings.append(
{"code": code, "source": source, "seltext": seltext,
"pos0": pos0, "pos1": pos1, "date": now}
)
_write_manifest(path, data)
return {"ok": True, "code": code, "summary": summary(str(path))}
三件事:校验区间、必要时自动建代码、按四元组去重。manifest 分支用列表推导先删同键旧记录再追加,等价于 SQL 的 INSERT OR REPLACE。
6.4 回读:list_codings
def list_codings(project_path: str, code: str = "", source: str = "") -> list[dict[str, Any]]:
"""Codings, optionally filtered by code and/or source. Newest first."""
path = Path(project_path)
if not path.is_dir():
raise FileNotFoundError(f"not a QualCoder project: {path}")
result: list[dict[str, Any]] = []
if _sqlite_ok(path):
try:
with sqlite3.connect(path / "data.qda") as conn:
sql = ("SELECT cn.name, s.name, ct.seltext, ct.pos0, ct.pos1, ct.date "
"FROM code_text ct "
"JOIN code_name cn ON cn.cid = ct.cid "
"JOIN source s ON s.id = ct.fid")
where: list[str] = []
params: list[Any] = []
if code:
where.append("cn.name = ?"); params.append(code)
if source:
where.append("s.name = ?"); params.append(source)
if where:
sql += " WHERE " + " AND ".join(where)
sql += " ORDER BY ct.ctid DESC"
for name, src, seltext, pos0, pos1, date in conn.execute(sql, params).fetchall():
result.append({"code": name, "source": src, "seltext": seltext,
"pos0": pos0, "pos1": pos1, "date": date})
return result
except Exception:
pass
data = _read_manifest(path)
for item in data.get("codings", []):
if code and item.get("code") != code:
continue
if source and item.get("source") != source:
continue
result.append({"code": item.get("code", ""), "source": item.get("source", ""),
"seltext": item.get("seltext", ""), "pos0": item.get("pos0", 0),
"pos1": item.get("pos1", 0), "date": item.get("date", "")})
result.reverse()
return result
ArkTS 的 refreshCodes() 用 list_codings(path, '', selectedName) 拿到当前资料的高亮数据,再交给 buildChunks() 渲染。字段与 CodingEntry 接口一一对应,这就是跨语言契约的最后一环。
七、真机验收:从冷启动到编码闭环

图 5:真机编码实拍。载入内置示例访谈 sample_interview.txt 后,在右栏新建代码 go,切到选取模式拖选句子并编码;中栏「以前晚上能安安静静备课改作业,现在一半时间在应付检查材料。」已带彩色高亮,底部状态显示「当前资料已编码 1 段 · 分块 3」,右栏编码记录同步出现。底部日志栏还打出了 summary 的 JSON:{..., "sources":0, "codes":1, "codings":1, "storage":"manifest"}——双通道降级与「载入 ≠ 入库」两个设计在此同框可见。
7.1 五步动线
| 步 | 操作 | 期望 |
|---|---|---|
| 1 | 冷启动 | 日志依次:运行时解压 / home … / CPython 3.12 已连接 / MVP_CORE_SUCCESS;三栏完整渲染 |
| 2 | 载入示例访谈 | 中栏显示全文,右栏代码列表刷新,无 ForEach 报错 |
| 3 | 导入当前资料 | 左栏 资料 N +1,documents/sample_interview.txt 落盘 |
| 4 | 加两个代码 → 拖选 → 编码 | 自动回查看模式、彩色高亮出现、编码记录新增、统计递增 |
| 5 | 点高亮 / 撤回 / 清空 / 重启 | 状态栏提示代码名;统计同步;重启后代码与编码全部恢复 |
7.2 边界与验收矩阵
| 维度 | 检查点 |
|---|---|
| 运行时 | 首启解压、二次复用、os.py 校验、marker 内容匹配 |
| 内核 | runTest 全绿、Python 版本打印、traceback 可在日志栏看到 |
| 文件 | picker 授权、7 种后缀、UTF-8 / GB18030、超长文本、空文件 |
| 代码本 | 新增、重复名、删除级联、选中 / 取消、计数 |
| 编码 | 空选区拦截、空代码名拦截、重复区间去重、超界区间过滤、撤回、批量清空 |
| 渲染 | 相邻 / 重叠区间分块、6 色高亮、点击查看、分块数提示 |
| 持久化 | manifest 落盘、summary.storage、重启恢复、documents/ 原文一致 |
| 系统 | 零权限、窗口缩放、2in1 / 平板布局、无 native 崩溃 |
排障口诀:任何「看起来没反应」的问题,先看底部日志栏。这一版把 Python 回包和 traceback 原样显示,绝大多数故障第一次点击后就能定位,不必连调试器。
八、上架工程实践:构建、签名、脱敏与合规
8.1 应用配置
AppScope/app.json5:
{"app": {"bundleName": "org.qualcoder.native", "vendor": "qualcoder",
"versionCode": 1020000, "versionName": "1.2.0",
"icon": "$media:app_icon", "label": "$string:app_name"}}
entry/src/main/module.json5 只有一个 Ability,且零权限:
{
"module": {
"name": "entry",
"type": "entry",
"mainElement": "EntryAbility",
"deviceTypes": ["2in1", "tablet"],
"pages": "$profile:main_pages",
"abilities": [{
"name": "EntryAbility",
"srcEntry": "./ets/entryability/EntryAbility.ets",
"exported": true,
"skills": [{ "entities": ["entity.system.home"],
"actions": ["action.system.home"] }]
}],
"requestPermissions": []
}
}
EntryAbility 薄到只有一句 loadContent:
onWindowStageCreate(windowStage: window.WindowStage): void {
hilog.info(0x0000, DOMAIN, 'onWindowStageCreate');
windowStage.loadContent('pages/Index', (err) => {
if (err.code) {
hilog.error(0x0000, DOMAIN, 'loadContent failed: %{public}s', JSON.stringify(err));
return;
}
hilog.info(0x0000, DOMAIN, 'loadContent succeeded');
});
}
没有后台服务、没有自定义 URI scheme、没有 fileProvider、没有对外 IPC,runCode 也没有暴露给任何其他应用。
8.2 构建命令
$env:DEVECO_SDK_HOME = 'C:\Program Files\Huawei\DevEco Studio\sdk'
$env:JAVA_HOME = 'C:\Program Files\Huawei\DevEco Studio\jbr'
$env:PATH = "$env:JAVA_HOME\bin;$env:PATH"
cd ohos_hap
ohpm install --prefix entry
& 'C:\Program Files\Huawei\DevEco Studio\tools\hvigor\bin\hvigorw.bat' `
--mode module -p module=entry@default -p product=default assembleHap --no-daemon
两个环境坑:命令行构建必须设 DEVECO_SDK_HOME,且把 DevEco jbr\bin 前置到 PATH,否则 PackageHap 阶段 spawn java ENOENT(00308018);compatibleSdkVersion 要与 targetSdkVersion 对齐(本项目 6.0.1(21) / 6.0.2(22)),误写 compatibleSdkVersionStage 或升到 beta 会让 import lazy 报 10705000。
8.3 签名材料脱敏(开源前必做)
本机 build-profile.json5 原本长这样(已脱敏示意,切勿提交真实值):
"signingConfigs": [{
"name": "default",
"type": "HarmonyOS",
"material": {
"storeFile": "C:/Users/<user>/Downloads/p12.p12",
"storePassword": "<加密口令>",
"keyAlias": "<alias>",
"keyPassword": "<加密口令>",
"signAlg": "SHA256withECDSA",
"profile": "C:/Users/<user>/Downloads/xxxRelease.p7b",
"certpath": "C:/Users/<user>/Downloads/public.cer"
}
}]
推到公开仓库的版本改成:
{
"app": {
"products": [{
"name": "default",
"compatibleSdkVersion": "6.0.1(21)",
"targetSdkVersion": "6.0.2(22)",
"runtimeOS": "HarmonyOS",
"buildOption": { "nativeLib": {
"collectAllLibs": true,
"debugSymbol": { "strip": false }
}}
}],
"signingConfigs": []
},
"modules": [{
"name": "entry", "srcPath": "./entry",
"targets": [{ "name": "default", "applyToProducts": ["default"] }]
}]
}
同时新增 .gitignore 挡住生成物与本机配置:
# 本机签名材料、IDE 配置与 SDK 路径(严禁入库)
local.properties
.idea/
.clangd
.clang-tidy
*.p12
*.p7b
*.cer
*.dmp
# hvigor / ohpm / CMake 生成物
.hvigor/
oh_modules/
entry/oh_modules/
**/.cxx/
**/build/
**/outputs/
node_modules/
# 构建产物
*.hap
*.app
*.hsp
*.har
# 日志
*.log
这一步把约 300 MB 的 build/、.idea/、entry/build/ 挡在仓库外,最终开源 300 个文件、可完整编译。克隆者只需在 DevEco 里对 org.qualcoder.native 做一次自动签名即可出包——签名 profile 与 bundleName 绑定,换包名必须先换签名材料,否则安装会报 9568289 类错误。
8.4 体积拆解
| 组成 | 体积 |
|---|---|
libpython3.12.a(whole-archive 整链) | 33.32 MB |
ohos_python_stdlib.zip | 4.39 MB |
54 个 C 扩展 .so | ~10 MB |
| CPython 头文件 / ArkTS / 图标 / 界面留证 | 少量 |
| 签名 HAP | 38.78 MB(unsigned 38.12 MB) |
一个自带完整 Python 3.12 解释器、能跑真实 Python 领域代码的应用,最终 38.78 MB——这是"只搬契约、不搬 161 个模块"的直接收益。
8.5 上架前技术自查
| 检查项 | 本工程状态 |
|---|---|
| 权限最小化 | requestPermissions: [],无 ACL、无网络 |
| 外部文件访问 | 仅 DocumentViewPicker 用户授权,数据只进沙箱 |
| 不 exec 外部二进制 | 无 fork / exec / popen / system |
| 不动态下载代码 | 运行时随 HAP 分发,PYTHONDONTWRITEBYTECODE=1 |
| 敏感资料 | 仅落 filesDir,无外发通道(清单明文存储需后续加密) |
| 包体与启动 | 38.78 MB,首启解压一次,marker 后秒进 |
| 崩溃可观测 | 底部日志栏 + traceback + hilog |
| 开源合规 | 签名材料已清空、生成物已忽略;LICENSE 与上游 LGPL-3.0 需统一 |
最后一条要如实说明:仓库初始的 LICENSE 是 MIT 占位模板、oh-package.json5 写着 "license": "MIT",而上游 QualCoder 是 LGPL-3.0。发布时应统一协议并补全版权行,别留模板默认值。
九、软件迁移能力自评(T0 / T1 / T2)
按能力覆盖口径给结论,状态以鸿蒙 PC 真机实测为准(能用 / 未适配,未适配是按平台特点主动分期,不是缺陷)。
T0 基础能力
| 能力 | 状态 | 依据 |
|---|---|---|
| 编译打包成 HAP | 能用 | DevEco 与 hvigorw 均可,已出签名包 |
| 签名 / 安装 / 启动 / 不白屏 | 能用 | 应用市场已上架,三栏完整渲染 |
| 运行时首启部署 + 二次复用 | 能用 | zip → filesDir/ohos_python,marker 幂等 |
| 静态 CPython 3.12 进程内初始化 | 能用 | Py_InitializeEx(0) + 单例 mutex |
54 个 C 扩展 dlopen | 能用 | HAP libs 路径 + RTLD_GLOBAL + DT_NEEDED 补丁 |
| 内核自检 / 异常回传 | 能用 | MVP_CORE_SUCCESS,traceback 可在日志栏读 |
| 零权限运行 | 能用 | requestPermissions: [] |
T0:100%
T1 日常核心能力
| 能力 | 状态 |
|---|---|
| 项目创建 / 统计 | 能用 |
| 外部文本载入(7 后缀、中文自适应) | 能用 |
| 资料导入与原文落盘 | 能用 |
| 代码本 CRUD / 选中 / 计数 | 能用 |
| 区间编码 / 去重 / 校验 | 能用 |
| 高亮渲染 / 点击查看 | 能用 |
| 编码记录 / 单条撤回 / 批量清空 | 能用 |
| 重启持久化 | 能用 |
T1:100% —— 载入 → 导入 → 建代码 → 选取 → 编码 → 高亮 → 撤回 → 重启恢复,端到端闭环。
T2 增强能力
| 能力 | 状态 | 说明 |
|---|---|---|
真 data.qda / 桌面版互通 | 未适配 | 差一个 _sqlite3 扩展,优先级最高 |
| 多项目 / 代码树 / 分类 | 未适配 | 后续迭代 |
| 图片 / 音视频编码 | 未适配 | 依赖 VLC / ffmpeg 在 OHOS 的可行性评估 |
| 富格式导入(PDF / docx / epub) | 未适配 | 依赖 pymupdf / EbookLib |
| 报告 / 图表 / 词云 | 未适配 | 上游 report_*.py 十余个模块 |
| AI / 向量检索 / MCP | 未适配 | 云端能力还需单独声明隐私 |
| 轻量增强(按资料过滤、区间分块、6 色着色、编码计数) | 能用 | 已内建 |
T2:约 5%
一句话总结:迁移深度上是 T1 引擎迁移级 + T0 级原生 ArkUI 壳;能力覆盖上 T0 100% / T1 100% / T2 约 5%。一个能在鸿蒙 PC 上真正做文本开放式编码的 QualCoder 精简版已经跑通并上架。
十、已知限制与后续路线
| # | 问题 | 影响 | 建议 |
|---|---|---|---|
| 1 | 缺 _sqlite3 | 走 JSON 清单,不能与桌面版 .qda 互通 | 交叉编译 _sqlite3 入 libs |
| 2 | add_code SQLite 分支直接 INSERT | 重复名触发 UNIQUE;manifest 分支不暴露 | 改 INSERT OR IGNORE |
| 3 | 选区是 UTF-16 code unit,Python 存 code point | emoji / 代理对文本位置可能偏移 | 桥接层统一按 Unicode scalar |
| 4 | 固定单项目 mvp.qda | 无法新建 / 切换 | 项目列表 + 目录 picker |
| 5 | pyQuote 未处理 \r / U+2028 | 极少数文本可能拼出异常字面量 | 结构化 RPC |
| 6 | runCode 可执行任意 Python 字符串 | 一旦有外部输入即形成注入面 | 改 callFunction(name, args) + 白名单 |
| 7 | qualcoder_mvp.json 明文存访谈原文 | 定性资料常含敏感信息 | 加密 + 隐私声明 |
| 8 | 代码颜色 6 色哈希 / 重叠区间先到先得 | 撞色、一段贴两码只显示第一个 | 持久化颜色、多重编码叠加 |
| 9 | refreshCodes 一次发 4 条 Python;UI 线程同步执行 | 调用数偏多、超大文本可能卡顿 | 合并 snapshot()、迁 Worker |
| 10 | LICENSE 与上游协议不一致 | 合规风险 | 统一协议并补全版权行 |
阶段路线:P0/P1(运行时与无头内核)✅ → P2(编码闭环)✅ → P2.5 真 data.qda → P3 媒体能力 → P4 工作台完善 → P5 可选 AI;横切项包括结构化 RPC、Worker 化、清单加密。
十一、从零复现:一份可勾的清单
- 准备 DevEco Studio 与 HarmonyOS SDK
6.0.1(21)/6.0.2(22),设好DEVECO_SDK_HOME、JAVA_HOME,jbr 前置 PATH。 git clone https://atomgit.com/OpenHarmonyPCDeveloper/ohos_QualCoder,确认三份真相源:Index.ets、ohos_python_stdlib.zip、领域层ohos_mvp.py(本地工作区_upstream/QualCoder/src/qualcoder/)。- 确认
cpp/prebuilt/libpython3.12.a(33.32 MB)与cpp/python_inc/在位;entry/libs/arm64-v8a有 54 个.so,且目录内没有杂文件。 - (可选)
python tools/build_runtime.py重建 zip →python tools/extract_dynload.py抽扩展 →python tools/patch_dynload_needed.py补DT_NEEDED。 - 若改过运行时内容,递增
Index.ets的RUNTIME_MARKER。 ohpm install --prefix entry。- DevEco 打开工程,对
org.qualcoder.native自动签名(仓库版本signingConfigs为空)。 hvigorw.bat --mode module -p module=entry@default -p product=default assembleHap --no-daemon。- 安装
entry-default-signed.hap,首启看底部日志是否出现QualCoder core ready与MVP_CORE_SUCCESS。 - 按第七章五步动线验收,再抽测边界用例(空选区、空代码名、重复编码、同名资料覆盖、重启恢复)。
- 出图留证:空工作台、示例载入、选取模式、编码高亮、多代码 / 关于。
十二、常见问题(Q&A)
按主题分成五组,全部按工程源码与鸿蒙 PC 真机实测回答。与正文重合的部分只给结论并指明章节,不重复展开。
12.1 路线与选型
Q1:为什么不套个兼容容器(T2),把桌面版 QualCoder 原样跑起来?
因为上游的依赖链在 aarch64-ohos 上根本不成立:PyQt6 ≥ 6.5 没有对应的平台插件,pymupdf、python-vlc、EbookLib、pandas / matplotlib / plotly、langchain / faiss-cpu / transformers 等数十个包也没有可用轮子。真走 T2,等于要在 HAP 里再塞一套 Linux 用户态 + X/Wayland + Qt,包体数百 MB、启动以分钟计,且窗口管理、输入法、文件授权都受容器适配进度限制。本项目用 341 行纯标准库代码复刻数据契约,反而更轻(详见第二章)。
Q2:那为什么不索性把 161 个模块全量重写成 ArkTS(纯 T0)?
"161 个模块"里绝大部分是 GUI、报告、AI,真正不可轻易重写的是 .qda 的表结构与 (code, source, pos0, pos1) 编码语义。全量重写要再造 SQLite 兼容层、十几种文档解析器与图表生态,工作量与回归风险都不可控;而界面层重写只要 748 行 ArkTS。所以按价值切两层:领域层复用契约(T1),界面层原生重写(T0),不是非此即彼。
Q3:同样内嵌 CPython,ohos_Optiland 为什么放弃 T1,本工程却保留了?
差别在交互频率与单次计算量(第二章有对照表)。光学设计要求"改一个曲率半径立刻看光路",几十毫秒的跨语言开销就会打断编辑循环;定性编码是"每几秒一次人工点击、一次点击写一条记录",亚毫秒的字典与文件操作,几百毫秒延迟人类无感。T1 不是通用的坏路线,只在高频率即时反馈场景下才是。
Q4:这个应用到底算 T0 还是 T1?
看用哪套口径:
| 口径 | 结论 |
|---|---|
| 迁移深度(怎么搬过来的) | T1 引擎迁移级(携带 CPython 运行时 + NAPI 桥),但 UI 壳是 T0 标准 |
| 能力覆盖(能用了多少) | T0 基础 100% / T1 日常 100% / T2 增强约 5%(第九章) |
两套编号含义不同,读其他鸿蒙移植文章时先确认作者用的是哪一套,别混读。
12.2 运行时与内核
Q5:为什么不用 python3.12 可执行文件 + 子进程?
三个原因叠加:HarmonyOS 沙箱禁止 exec 外部二进制、filesDir 带 noexec 语义;OHOS 社区移植的定制 libpython 在 fork 子进程里 Py_BytesMain / Py_Main 会"空跑"(返回 0、不执行 argv 脚本);且 io 层写盘静默丢失,只有 os.write(1, …) 直通系统调用可靠。本项目绕开整条链路:Py_InitializeEx(0) 一次常驻,解释器就是应用进程里的一段代码,不 fork、不 exec、无 IPC(第四章)。
Q6:为什么 stdout/stderr 用文件捕获,而不是管道?
文件方案简单、可读、可留证,且规避了子进程 io 层不可靠的问题。C++ 把业务代码缩进后包进 _Capture 模板,sys.stdout / sys.stderr 指向 py_capture_out.txt / py_capture_err.txt,finally 里 flush + close,再拼成带 --- stdout --- / --- stderr --- 分隔符的报告。代价是每次调用两次小文件读写,在本工程的调用频率下完全无感。
Q7:54 个 C 扩展为什么必须放进 entry/libs/arm64-v8a/,不能留在 zip 里?
HarmonyOS 链接器命名空间禁止对应用沙箱 filesDir(el2)里解压出的 .so 执行 dlopen。所以扩展必须随 HAP 的 libs(el1 bundle 路径)分发,C++ 侧用 dladdr 推自身目录、math…so 当探针,并 dlopen(self, RTLD_GLOBAL | RTLD_NOLOAD) 提升符号域,最后用 lief 给每个扩展补 DT_NEEDED libqualcoder_native.so。少任何一步,表现都是"解释器起来了但 import math 全崩"(第三章)。顺带记住:libs 目录里只能有 .so,collectAllLibs: true 会把杂文件一起打进包。
Q8:改了 ohos_mvp.py 并重打包,为什么代码看起来完全没生效?
十有八九是运行时 marker 没递增。首启判据是:
const RUNTIME_MARKER = 'READY_QUALCODER_P1_0_0_2';
const marker = `${this.pythonHome}/.${RUNTIME_MARKER}`;
const osPy = `${this.pythonHome}/lib/python3.12/os.py`;
if (!openable(osPy) || !exists(marker) || readText(marker).indexOf(RUNTIME_MARKER) < 0) {
// 解压 zip 并写 marker
} else {
this.log('复用已部署运行时'); // ← 改动被旧沙箱文件吃掉
}
改 zip 内容后必须同步递增这个常量(或清应用数据重装),否则应用会一直复用上一次解压出来的旧 .py。
Q9:38.78 MB 的包体还能再小吗?
大头是 libpython3.12.a(33.32 MB,--whole-archive 整链)、stdlib zip(4.39 MB)与 54 个扩展(约 10 MB)。可行方向:裁剪 stdlib、按实际用到的模块保留扩展、对解释器做 -Os + --gc-sections、开启 strip。但每一条都在削"真实 CPython"的保真度,且与保留 strip: false 便于真机抓栈的初衷冲突,需要按发布目标权衡。
12.3 数据与持久化
Q10:summary().storage 为什么是 manifest 而不是 sqlite?怎么切回真 .qda?
54 个扩展里有 zlib、_json、_datetime、_socket,但没有 _sqlite3(也没有 _ssl)。领域层用一条集中判据自动降级:
def _sqlite_ok(path: Path) -> bool:
return (
sqlite3 is not None
and (path / "data.qda").is_file()
and _read_manifest(path).get("storage") == "sqlite"
)
所有读写先过它,所以只要把交叉编译好的 _sqlite3.cpython-312-aarch64-linux-ohos.so 放进 entry/libs/arm64-v8a/,create_project() 就会建出真的 data.qda,业务代码一行都不用改(第六章)。图 5 日志里的 "storage":"manifest" 就是当前降级的现场证据。
Q11:生成的 data.qda 能和桌面版 QualCoder 直接互开吗?
当前不能(还没生成真 .qda)。表结构已按上游 5 张表定义、字段也对齐,但 P2.5 完成后还需验证两处语义:ArkTS 选区是 UTF-16 code unit、Python 存的是 Unicode code point(中文 BMP 一致,emoji / 代理对会错位),以及 code_text 的 owner / fid 映射。做完这两项才算真正互通。
Q12:杀掉进程重启后,代码和编码为什么还在?
启动时 prepareRuntime() 成功后依次调用 ensureProject()(create_project 幂等,目录与 manifest 已存在则不重建)和 refreshCodes()(重拉 list_codes / list_codings / list_codings(path,'',selectedName) / summary),把四类 @State 从沙箱重新灌回界面。所有数据都在 filesDir/QualCoderProjects/mvp.qda/,不依赖内存态。
Q13:载入示例访谈后,左栏 sources 为什么还是 0?支持哪些资料格式?
因为载入 ≠ 入库:loadSampleText() / pickText() 只把文本放进 selectedText,要点「导入当前资料」才调 import_text 写进 documents/ 并登记。这个设计让"先浏览、再决定入库"成为可能,也是图 5 里 sources: 0, codes: 1, codings: 1 的由来。当前支持 .txt/.md/.csv/.html/.htm/.tex/.rtf 七种纯文本后缀,UTF-8 优先、检测到 \uFFFD 回退 GB18030;PDF / docx / epub 与图片音视频属于后续阶段。
12.4 UI 与交互
Q14:编码高亮为什么用 Span.textBackgroundStyle,而不是 backgroundColor?
Span 不是普通组件,.backgroundColor() 会被静默忽略——不报错、也不生效。同类还有:属性链里调用私有方法(如 .fontColor(colorForCode(...)))同样被忽略。所以颜色与 bold 必须在 buildChunks() 里预计算进 TextChunk 字段,属性链只读字段:
Span(chunk.text)
.fontWeight(chunk.bold ? FontWeight.Bold : FontWeight.Normal)
.textBackgroundStyle({ color: chunk.color, radius: 4 })
Q15:为什么点「编码选中文字」时,刚拖好的选区会莫名消失?
点按钮会让 TextArea 失焦,触发一次 onTextSelectionChange(start == end);如果无脑写状态,就把选区覆盖成空了。修法是只在非空选区时更新,模式切换时才显式重置:
.onTextSelectionChange((start: number, end: number) => {
if (end > start) {
this.selStart = start;
this.selEnd = end;
}
})
Q16:为什么 add_code 一返回界面就闪退?
add_code / delete_code 返回的是 dict {ok, name, codes},早期整个赋给 @State codeEntries: CodeEntry[],ArkUI 抛 data source dose not have forEach function(103806)并杀进程。修法是取 .codes + Array.isArray() 校验;所有 JSON.parse 结果赋给数组状态的地方都要加这层防护。这条与 Q14、Q15 合起来就是第五章那"三个会杀进程的静默坑"。
Q17:零权限是怎么读到用户桌面上的文件的?
module.json5 的 requestPermissions: [],不申请任何存储权限。外部文件靠 DocumentViewPicker.select() 由用户当场授权返回 uri,再 fileIo.openSync(uri, READ_ONLY) → statSync(file.fd) → readSync(file.fd, buffer) → closeSync(file)。注意 openSync 返回的是 File 对象,读写要传 .fd,util.TextDecoder 要 Uint8Array。授权是一次性的,数据只进应用沙箱,不会写回原路径。
12.5 构建、上架与安全
Q18:克隆仓库后为什么构建不出签名包 / 装不上真机?
因为仓库版本的 build-profile.json5 故意把 signingConfigs 清空了——原配置含本机 p12 / p7b / cer 的绝对路径与加密口令,绝不能进公开仓库(第八章有前后对比)。克隆后请在 DevEco Studio 里对 org.qualcoder.native 做一次「Signing Configs → 自动签名」。签名 profile 与 bundleName 绑定,换包名必须先换签名材料,否则安装报 9568289 类错误。
Q19:命令行构建报 spawn java ENOENT / 00308018,或 import lazy 报 10705000?
前者是 PackageHap 阶段找不到 java:
$env:DEVECO_SDK_HOME = 'C:\Program Files\Huawei\DevEco Studio\sdk'
$env:JAVA_HOME = 'C:\Program Files\Huawei\DevEco Studio\jbr'
$env:PATH = "$env:JAVA_HOME\bin;$env:PATH"
后者通常是 SDK 版本被自动签名改写:compatibleSdkVersion 要与 targetSdkVersion 对齐(本项目 6.0.1(21) / 6.0.2(22)),不要写 compatibleSdkVersionStage,也不要误升到 beta。
Q20:runCode 能执行任意 Python 字符串,这安全吗?
当前风险可控但不应长期保留:调用串全部由应用内部拼接,没有外部输入路径;Ability 只有 home skill,没有自定义 scheme、fileProvider 或对外 IPC,runCode 没有暴露给第三方。但两个已知短板是真实的——pyQuote() 只转义 \、'、换行,未覆盖 \r / U+2028;qualcoder_mvp.json 明文存访谈原文,而定性资料往往敏感。演进方向是结构化 RPC(callFunction(name, argsJson) + 函数白名单)与清单加密,两者都在第十章的横切路线里。
Q21:能申请网络权限接 LLM 做 AI 辅助编码吗?
技术上可以(_socket 扩展在包里),但这会直接打破当前"零权限、无外发通道"的隐私优势。访谈稿常含可识别个人信息,一旦走云端就必须单独做隐私声明、用户明示同意与数据最小化说明,不能默默上传。所以 AI 被放在 P5(可选),且明确要求"若走云端须单独声明隐私与权限"。
Q22:仓库 LICENSE 是 MIT,上游 QualCoder 不是 LGPL-3.0 吗?
这是平台初始提交的 MIT 占位模板遗留([year] [fullname] 未填),oh-package.json5 也写着 "license": "MIT",与上游 LGPL-3.0 不一致。本工程新增的 ArkTS / C++ / ohos_mvp.py 与上游源码的授权关系需要明确,发布时应统一协议并补全版权行,别留模板默认值。这是第十章登记在册的合规项,不是代码缺陷。
结语
把一个 161 模块的 PyQt6 桌面软件搬上鸿蒙 PC,最容易走的路是"套个容器把原程序塞进去",最诱人的路是"把 Python 全量翻译成 ArkTS"。这次选了中间那条:只把不可轻易重写的数据契约带过来,界面按原生标准重写,中间用一层 326 行的 NAPI 桥把两边接住。
回过头看,真正花时间的不是写代码,而是四件事:搞明白 HarmonyOS 链接器命名空间为什么不让 dlopen 沙箱里的 .so;接受"不 fork、进程内常驻"这个更朴素的模型;把跨语言返回值收敛成"最后一行单行 JSON";以及发现 ArkUI 里那些不报错、只是静默不生效的属性。
上架页上的 5.0 分和 <1 万次 安装只是起点。仓库已经开源,欢迎提交 Issue 与 PR——尤其是 _sqlite3 扩展,那是解锁下一个能力层级最关键的一块拼图。
更多推荐



所有评论(0)