把 QualCoder 搬上鸿蒙 PC:内嵌 CPython 3.12 + ArkTS 原生界面的「质码工坊」移植实战
把 QualCoder 搬上鸿蒙 PC:内嵌 CPython 3.12 + ArkTS 原生界面的「质码工坊」移植实战
欢迎加入开源鸿蒙 PC 社区:https://harmonypc.csdn.net/
欢迎在 PC 社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper
适配开源地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_QualCoder
本文记录的是一次真编译、真解释器、真数据模型的垂直切片移植:把交叉编译的 CPython 3.12 静态库整链进 N-API 原生模块,让上游 QualCoder 的 SQLite 表结构在鸿蒙沙箱里落地,用 ArkTS 重写桌面端交互,最后在 HarmonyOS PC 真机上跑通"载入访谈 → 选中文字 → 建立代码 → 高亮回显 → 删除/清空编码"的完整闭环。文中所有架构图、代码摘录、体积数据和五张截图,都来自可复现的本地工程与实机验证。

图 0:最终效果。选中访谈中的一句话后赋予代码 go,原文以蓝色背景高亮,右侧同步出现代码计数和编码记录,底部日志回显 Python 内核返回的 JSON。

摘要:这一次到底交付了什么
| 项目 | 结果 |
|---|---|
| 应用名 / 包名 | 质码工坊(from QualCoder) / org.qualcoder.native |
| 应用版本 | versionName 1.2.0(versionCode 1020000) |
| 目标设备 | 2in1、tablet(HarmonyOS PC 形态) |
| SDK | compatibleSdkVersion 6.0.1(21),targetSdkVersion 6.0.2(22),runtimeOS: HarmonyOS |
| 界面 | ArkUI / ArkTS 原生三栏工作台,零 WebView、零 Qt |
| Python 内核 | 交叉编译 CPython 3.12,libpython3.12.a 静态整链进 libqualcoder_native.so |
| 运行时 | rawfile/ohos_python_stdlib.zip 4.39 MB,首启解压到 filesDir/ohos_python |
| C 扩展 | 54 个 cpython-312-aarch64-linux-ohos.so 随 HAP libs/arm64-v8a 分发 |
| 数据核心 | QualCoder 兼容的 5 张 SQLite 表 + qualcoder_mvp.json 清单双通道 |
| 已跑通闭环 | 项目创建、文本导入、代码增删、文本区间编码、高亮渲染、编码撤回、清空资料编码 |
| 权限 | requestPermissions: [],文件访问走 DocumentViewPicker 用户授权 |
| 产物 | entry-default-signed.hap 38.78 MB(unsigned 38.12 MB) |
| 尚未实现 | PyQt6 界面、音视频编码、PDF/docx 解析、报告图表、分类层级、多编码者、AI 能力 |
一句话概括技术路线:UI 层彻底原生化,业务层保留 Python,数据层向上游 schema 看齐。

一、缘起:鸿蒙特 PC 上缺的不是编辑器,是研究工具
鸿蒙 PC 的生态补齐,通常从"通用工具"开始:浏览器、终端、编辑器、IDE、截图、笔记。这些做完以后,剩下的缺口往往落在专业生产力软件上。定性研究(Qualitative Research)领域就是这样一块空白。
QualCoder 是这个方向上一个很有代表性的开源项目:它由 Python 编写,用来做定性资料分析(QDA)。研究者把访谈稿、田野笔记、政策文本导入项目,然后在原文上"编码"——给一段文字贴上一个概念标签(code),例如把"现在一半时间在应付检查材料"标记为 形式主义负荷;随后按代码检索、统计频次、比较不同编码者、生成报告。它支持 docx / epub / html / md / odt / rtf / tex / txt / PDF,也支持图片与音视频时间段编码,还能接 GPT-4 之类的模型辅助分析。
也就是说,它不是"一个带标签的文本编辑器",而是围绕**项目(project)—资料(source)—代码(code)—编码(coding)**四层数据模型建起来的研究工作台。这恰好让移植变得有意思:真正的难点不是把窗口画出来,而是让这套数据模型在鸿蒙沙箱里活着。
1.1 上游技术栈的"重量"
先看清对手。上游 QualCoder 的典型依赖是:
- PyQt6:整个桌面 GUI,几十个
.ui文件(本地上游副本里有 64 个) - SQLite:
data.qda项目数据库 - PyMuPDF / Pillow / python-docx / openpyxl / lxml:各类文档解析
- VLC / ffmpeg:音视频编码与波形
- OpenAI SDK 等:AI 辅助分析
- 若干 Python 3.11+ 语法与大量 C 扩展
在 Windows / macOS / Linux 上,pip install 加一个虚拟环境就能解决;在 HarmonyOS NEXT 应用沙箱里,这条路径基本不通:
- 没有系统 Python,也不能假设能
exec一个解释器:应用filesDir属于 el2 数据区,不是可执行文件安装位。 - 没有 X11 / Wayland,也没有现成的 PyQt6 for OHOS wheel:即使把 Qt 交叉编译过来,Qt 平台插件、字体、剪贴板、拖放、原生对话框仍是一整套工程。
dlopen有链接器命名空间限制:解压到 el2 的.so不能当扩展模块加载。- 沙箱权限模型不同:桌面软件默认"读整块磁盘",鸿蒙应用默认"只能读自己的沙箱 + 用户通过 picker 显式授予的文件"。
所以,"把源码拷进去跑 python qualcoder/__main__.py"这条最朴素的路,从第一步就断了。
1.2 本次移植的目标边界
把目标写清楚,比急着写代码重要。本次交付的是一个垂直切片(vertical slice),而不是"完整 QualCoder":
- 必须真实运行 CPython 解释器,不用 JS 模拟业务逻辑;
- 必须保留上游项目数据模型的语义(表名、字段、区间编码);
- 必须在真机上完成一次端到端的编码操作,且重启后数据还在;
- 明确标注未实现能力,不用"Demo 截图"冒充完整移植;
- 全程零敏感权限,不联网、不上传资料。
这条边界决定了后面所有的取舍。
二、技术选型:三条路线,为什么最终是"混合重写"

2.1 为什么"UI 原生化"不是妥协
桌面端用户对界面的要求与手机不同:三栏布局、常驻代码表、精确文本选区、鼠标拖选、状态栏、宽屏信息密度。ArkUI 的 Row/Column/List/TextArea/Span 已经能直接表达这些,不需要为了复用 PyQt 控件树去背一整套 Qt 平台适配的债。
更关键的是,QDA 的核心资产是数据,不是控件。只要 source / code_name / code_text 这几张表的语义保住,界面换一层皮并不会让研究成果失真;未来无论补 Web 版、补桌面版,数据都能互通。
2.2 为什么"业务层保留 Python"
ohos_mvp.py 只有 341 行,但它承担了真正的领域逻辑:项目目录规范、资料落盘、代码去重、编码区间去重、级联删除、按代码/资料过滤、统计汇总。把这些逻辑放在 Python 里,好处很直接:
- 与上游 QualCoder 的 SQLite schema 可以逐字段对齐;
- 后续补齐
_sqlite3扩展即可无缝切回data.qda,与桌面版项目文件互通; - 领域逻辑可以用宿主 Python 直接跑单元测试,不必依赖设备;
- 上层 ArkTS 只做状态渲染与用户意图翻译,职责清晰。
2.3 分层架构
四层的职责边界:
| 层 | 关键文件 | 职责 | 不负责 |
|---|---|---|---|
| UI | ets/pages/Index.ets | 状态机、三栏布局、选区、高亮分块、错误提示 | 不写 SQL、不拼文件路径到 UI 之外 |
| 桥 | cpp/qualcoder_napi.cpp | 解释器生命周期、线程互斥、输出重定向、自检 | 不解析业务 JSON |
| 运行时 | CMakeLists.txt + rawfile/*.zip + libs/*.so | 让 CPython 在 el2 数据区可用 | 不含上游 PyQt6 |
| 存储 | _upstream/QualCoder/src/qualcoder/ohos_mvp.py | 项目/资料/代码/编码领域逻辑与双通道持久化 | 不管界面 |
三、工程结构:每个文件在解决什么问题
工程根目录 ohos_QualCoder,DevEco / hvigor 工程根是 ohos_hap:
ohos_QualCoder/
├─ README.md # 工程说明、构建命令、阶段路线
├─ ohos_hap/ # HarmonyOS 工程根
│ ├─ AppScope/
│ │ ├─ app.json5 # bundleName / version / 图标
│ │ └─ resources/base/element/string.json # ★ 应用显示名「质码工坊(from QualCoder)」
│ ├─ build-profile.json5 # SDK 版本、collectAllLibs、strip=false、签名槽位
│ └─ entry/
│ ├─ oh-package.json5 # 依赖 libqualcoder_native.so(本地 file: 模块)
│ ├─ libs/arm64-v8a/ # 54 个 CPython C 扩展 .so
│ └─ src/main/
│ ├─ module.json5 # entry ability、2in1/tablet、requestPermissions: []
│ ├─ ets/entryability/EntryAbility.ets # 生命周期 + loadContent('pages/Index')
│ ├─ ets/pages/Index.ets # ★ 748 行三栏工作台(UI + 状态机)
│ ├─ cpp/CMakeLists.txt # 静态整链 libpython3.12.a
│ ├─ cpp/qualcoder_napi.cpp # ★ 326 行 N-API 宿主
│ ├─ cpp/prebuilt/libpython3.12.a # 33.32 MB 交叉编译静态库
│ ├─ cpp/python_inc/ # 214 个 CPython 头文件
│ ├─ cpp/types/libqualcoder_native/ # index.d.ts + oh-package.json5
│ └─ resources/rawfile/ohos_python_stdlib.zip # 4.39 MB 运行时
├─ tools/
│ ├─ build_runtime.py # 重建运行时 zip(注入 ohos_mvp.py)
│ ├─ dump_view.ps1 / dump_view.py # 真机界面结构辅助脚本
│ └─ extract_dynload.py / patch_dynload_needed.py # C 扩展处理脚本
└─ _upstream/QualCoder/ # 上游源码副本(LGPL-3.0)
├─ LICENSE.txt
└─ src/qualcoder/
├─ __init__.py
└─ ohos_mvp.py # ★ 341 行鸿蒙精简领域核心
三份"真相源"必须分清,否则改错地方会浪费一整晚:
- 界面真相源:
ohos_hap/entry/src/main/ets/pages/Index.ets - 业务真相源:
_upstream/QualCoder/src/qualcoder/ohos_mvp.py - 运行时产物:
ohos_hap/entry/src/main/resources/rawfile/ohos_python_stdlib.zip
改了第 2 项,必须执行 python tools\build_runtime.py 重新打包,并递增 Index.ets 里的 RUNTIME_MARKER,否则设备会复用上次解压的旧目录,出现"代码明明改了却毫无变化"的假象。
3.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"}}
AppScope/resources/base/element/string.json:
{"string": [{"name": "app_name", "value": "质码工坊(from QualCoder)"}]}
module.json5 里 deviceTypes 是 ["2in1", "tablet"],requestPermissions 是空数组。零权限是刻意设计:应用只写自己的 filesDir,读取外部资料时由 DocumentViewPicker 弹系统文件选择器,用户选谁就读谁,不需要申请"读取存储"这类宽泛权限。
3.2 构建配置里两个不能删的开关
"buildOption": {
"nativeLib": {
"collectAllLibs": true,
"debugSymbol": { "strip": false }
}
}
collectAllLibs: true:把libs/arm64-v8a下 54 个 CPython 扩展全部收进 HAP。漏了它,import math这类标准库就会在设备上失败。strip: false:静态整链的解释器符号需要保留给扩展模块重定位;剥符号在这条技术路线上是踩过的坑。
提示:
build-profile.json5里有本机签名材料路径与加密口令,属于个人环境信息,本文不复述;换机时在 DevEco Studio 里对该bundleName重新自动签名即可,签名 profile 与包名绑定,不能跨工程混用。
四、让 CPython 在鸿蒙应用里"活下来"
这是整个移植最硬的一段。核心矛盾只有一个:鸿蒙应用沙箱的数据区不能执行二进制,但 Python 解释器既需要代码,也需要 stdlib,还需要 C 扩展。
4.1 静态整链:把解释器塞进 N-API 的 .so
# ohos_hap/entry/src/main/cpp/CMakeLists.txt
add_library(qualcoder_native SHARED qualcoder_napi.cpp)
target_link_options(qualcoder_native PRIVATE -Wl,--export-dynamic)
target_link_libraries(qualcoder_native PUBLIC
-Wl,--whole-archive
"${PYTHON_LIB}" # prebuilt/libpython3.12.a
-Wl,--no-whole-archive
libace_napi.z.so
libhilog_ndk.z.so
libc++.a libm.a libdl.a
)
三个关键点:
| 开关 | 作用 | 不做会怎样 |
|---|---|---|
--whole-archive | 把 33.32 MB 静态库整体拉进 libqualcoder_native.so | 只链进被直接引用的符号,PyRun_SimpleString 之后再加载的模块找不到大量 Py* 符号 |
--export-dynamic | 把宿主 .so 的 dynsym 暴露到全局 | 扩展模块 dlopen 时无法对宿主里的 libpython 符号重定位 |
strip: false | 保留动态符号 | 同上,且问题只在真机出现,模拟器/编译期毫无征兆 |
于是没有 python3.12 可执行文件,也不 fork 子进程:解释器就是应用进程里的一段代码,由 Py_InitializeEx(0) 初始化一次。
4.2 stdlib 当"数据"部署:rawfile → filesDir
ArkTS 侧的 prepareRuntime() 完成了首启部署:
// Index.ets(节选)
const RUNTIME_ZIP = 'ohos_python_stdlib.zip';
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) {
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);
fileIo.writeSync(zip.fd, content.buffer as ArrayBuffer);
fileIo.closeSync(zip);
await zlib.decompressFile(zipPath, this.filesDir);
if (!openable(osPy)) {
throw new Error(`运行时缺少 ${osPy}`);
}
writeText(marker, `${RUNTIME_MARKER}\n`);
}
const homeResult = qualcoderNative.setPythonHome(this.pythonHome);
if (homeResult.indexOf('ERR') >= 0 || homeResult.indexOf('os.py=n') >= 0) {
throw new Error(homeResult);
}
这里面有四个防御性设计:
- 以
os.py是否可读作为"stdlib 完整"的判据,比判断目录存在可靠得多; .READY_QUALCODER_P1_0_0_2版本标记:改了 Python 业务代码后,只要递增常量,设备就会重新解压,不需要手动清数据;setPythonHome返回诊断串而不是布尔值:C++ 侧ProbeHome()会回传home_access=y home_dir=y lib_dir=y os.py=y home_ls=[...],真机上一眼看出是路径错、解压失败还是缺文件;- 失败即时抛错并写日志栏,不会带着坏运行时继续进业务。
成功链路:
HAP 安装
→ aboutToAppear()
→ getRawFileContent('ohos_python_stdlib.zip')
→ zlib.decompressFile 到 filesDir/
→ filesDir/ohos_python/lib/python3.12/{os.py, site-packages/qualcoder/...}
→ setPythonHome(filesDir/ohos_python)
→ 首次 runCode 触发 Py_InitializeEx(0)
→ runTest() 打印 QualCoder core ready / MVP_CORE_SUCCESS
→ ensureProject() + refreshCodes() + 渲染 UI
4.3 C 扩展:不能放在解压目录,必须放在 HAP 的 libs 里
build_runtime.py 里有一句注释点破了关键约束:
# CPython C extensions ship inside the HAP libs dir instead: HarmonyOS linker
# namespaces forbid dlopen on files extracted under the el2 files directory,
# so keeping them in the zip is dead weight.
DYNLOAD_PREFIX = "ohos_python/lib/python3.12/lib-dynload/"
所以打包脚本会删掉 zip 里所有 lib-dynload/*.so,改由 entry/libs/arm64-v8a/ 提供 54 个扩展(math、_json、_datetime、zlib、_socket、array、binascii……)。C++ 侧用 dladdr 找到宿主 .so 自己的路径,推导出 libs 目录:
// qualcoder_napi.cpp(节选)
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);
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);
Py_SetPythonHome(wHome);
Py_InitializeEx(0);
dlopen(self, RTLD_NOLOAD | RTLD_GLOBAL) 不是重新加载自己,而是把已加载对象提升到全局符号域,让后续 dlopen 的扩展能解析到宿主里的 libpython 符号。math...so 在这里被当作"我确实找到了正确的 libs 目录"的探针。
PYTHONDONTWRITEBYTECODE=1 也别漏:stdlib 在 el2 数据区,写 __pycache__ 既无必要又可能触发权限/性能问题。
4.4 单例、互斥与输出捕获
ArkTS 是单线程 UI,但 N-API 侧仍做了防护:static std::mutex g_pyMu + static bool g_pyInited,保证 Py_InitializeEx 只跑一次,所有 runCode 串行进入解释器。
Python 的 print 不会自动出现在 ArkTS 里,因此 RunPythonCode() 把用户代码缩进四行空格,包进一个带文件重定向的模板:
// qualcoder_napi.cpp(节选,模板语义)
"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(outFile); _err = _Capture(errFile)\n"
"sys.stdout = _out; sys.stderr = _err\n"
"try:\n" + indented +
"except Exception:\n"
" import traceback; traceback.print_exc()\n"
"finally:\n"
" _out.flush(); _err.flush(); _out.close(); _err.close()\n"
执行完 PyRun_SimpleString,C++ 读回两个文件,拼成统一报告:
[MVP] real CPython 3.12
[MVP] PyRun_SimpleString rc=0
--- stdout ---
{"path": "...", "name": "MVP 项目", "sources": 0, "codes": 0, "codings": 0, "storage": "manifest"}
--- stderr ---
ArkTS 侧 parseReport() 只取 --- stdout --- 与 --- stderr --- 之间的内容,runJson() 再取最后一行交给 JSON.parse。这样一次 N-API 调用就同时拿到了结果、异常栈和返回码,调试期直接把原文塞进底部日志栏——本文所有截图里那一行 JSON,就是这个通道的产物。
4.5 启动时自检:不靠"看起来能点"
runTest() 不是空函数,它在设备上的临时目录里真的建了一个项目、导入一份文本、再检查统计与落盘:
root = Path(tempfile.gettempdir()) / 'qualcoder_mvp_test.qda'
create_project(str(root), 'MVP Test')
import_text(str(root), 'interview.txt', 'HarmonyOS QualCoder MVP\n')
result = summary(str(root))
print('QualCoder core ready')
print('python', sys.version.split()[0])
assert result['sources'] == 1
assert (root / 'data.qda').is_file() or (root / 'qualcoder_mvp.json').is_file()
print('MVP_CORE_SUCCESS')
只有同时出现 MVP_CORE_SUCCESS 且 rc=0,C++ 才追加 [MVP] SUCCESS;ArkTS 的 runSelfCheck() 若找不到这个标记,就把状态栏改成"内核初始化异常,部分功能不可用"。图 1 底部日志里的 QualCoder core ready 就是这段自检在真机上的输出。
五、数据层:向上游 schema 看齐,同时准备降级
5.1 五张表,保留 QualCoder 的语义
ohos_mvp.py 里的 SCHEMA 没有自创格式:
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 | about(项目名)、databaseversion | 左栏「MVP 项目」 |
| 资料 | source | name 唯一、fulltext、mediapath | 左栏「资料 N」、中栏文件名 |
| 代码分类 | code_cat | supercatid hierarchical | 暂未开放 UI |
| 代码 | code_name | name 唯一、color、owner | 右栏代码列表 + 计数 |
| 编码 | code_text | pos0 / pos1 / seltext / fid | 中栏高亮 + 右栏编码记录 |
code_text 的 UNIQUE(cid, fid, pos0, pos1, owner) 很关键:它把"同一代码、同一资料、同一区间、同一编码者"定义为同一条编码,重复操作用 INSERT OR REPLACE 覆盖,而不是产生两条重复记录。
5.2 双通道:data.qda 与 qualcoder_mvp.json
当前 HAP 的 54 个扩展里没有 _sqlite3(实测只有 zlib、_json、_datetime、_socket 等),所以 import sqlite3 会失败。核心层对此做了显式降级:
try:
import sqlite3
except Exception:
sqlite3 = None
def _sqlite_ok(path: Path) -> bool:
return (sqlite3 is not None
and (path / "data.qda").is_file()
and _read_manifest(path).get("storage") == "sqlite")
create_project()尝试建 SQLite,成功则storage="sqlite",失败则storage="manifest";- 所有读写入口都先查
_sqlite_ok(),SQLite 可用走 SQL,否则走qualcoder_mvp.json清单; summary()把storage字段一并返回,界面日志能直接看到当前用的是哪条通道。
本文所有截图的日志里都是 "storage": "manifest",这不是 bug,而是当前扩展集下的预期行为。等 P2.5 交叉编译 _sqlite3 并放进 libs/arm64-v8a,data.qda 会成为真正的项目文件,桌面版 QualCoder 也能直接打开,业务代码一行都不用改。
清单文件长这样(字段与 CodingEntry 一一对应):
{
"name": "MVP 项目",
"storage": "manifest",
"sources": [{"name": "sample_interview.txt", "path": "...", "date": "..."}],
"codes": [{"name": "go", "memo": "", "date": "...", "color": "#3569a8"}],
"codings": [{"code": "go", "source": "sample_interview.txt",
"seltext": "前晚上能安安静静备课改作业...", "pos0": 9, "pos1": 17,
"date": "..."}]
}
5.3 领域 API 一览
| Python 函数 | 语义 | 幂等 / 边界 |
|---|---|---|
create_project(path, name) | 建 documents/images/audio/video 四目录 + 初始化 channel | 可重复调用;path 是目录(工程里叫 mvp.qda) |
import_text(path, name, content) | 写 documents/<name>,manifest 去重,SQL ON CONFLICT(name) DO UPDATE | 同名资料覆盖 |
summary(path) | 返回 {path,name,sources,codes,codings,storage} | 目录不存在抛 FileNotFoundError |
add_code(path, name, memo, color) | 新建代码,返回 {ok,name,codes} | manifest 分支幂等;SQLite 分支靠 UNIQUE 约束(见第十三章已知问题) |
delete_code(path, name) | 删代码,级联删该代码全部编码 | SQL 两表 + manifest 双过滤 |
code_selection(path, code, source, seltext, pos0, pos1) | 把代码贴到 [pos0,pos1) 区间 | 校验空代码、非法区间;同区间去重;必要时自动建代码 |
delete_coding(path, code, source, pos0, pos1) | 撤回单条编码 | 按四元组精确匹配 |
list_codes(path) | 代码 + 编码计数,按名排序 | SQLite LEFT JOIN ... GROUP BY / manifest 计数聚合 |
list_codings(path, code, source) | 编码列表,可按代码/资料过滤,最新在前 | SQLite ORDER BY ctid DESC / manifest reverse() |
一个容易被忽略的细节:SQLite 分支的 code_selection 会检查 source 表里是否存在该资料,不存在就抛 FileNotFoundError;manifest 分支不做这个校验。因此界面专门留了「导入当前资料」按钮,让用户在严谨路径下先把资料登记进项目——这也解释了为什么左栏是三个按钮而不是两个。
code_selection 的核心片段:
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))}
返回值是 dict,不是数组——这个事实后面会在 ArkUI 里引发一次进程级崩溃,详见第十章。
六、跨语言契约:用 print(json.dumps(...)) 当 RPC
N-API 只暴露四个方法,index.d.ts 干净得几乎不像一个桥:
interface QualCoderNativeApi {
getBuildInfo(): string;
setPythonHome(home: string): string;
runTest(): string;
runCode(code: string): string;
}
runCode 是通用 Python 执行入口,因此 ArkTS 里所有业务调用都长成同一个形状:
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();
}
private addCode(): void {
const result = this.runJson(
`import json\nfrom qualcoder.ohos_mvp import add_code\nprint(json.dumps(add_code(${this.pyQuote(path)}, ${this.pyQuote(value)}), ensure_ascii=False))`
);
const parsed = JSON.parse(result) as CodeOpResult;
...
}
约定只有四条:
- 每次调用都
import json,最后一行print(json.dumps(..., ensure_ascii=False)); ensure_ascii=False保证中文不变成\uXXXX,日志栏能直接读;- ArkTS 只取 stdout 的最后一行给
JSON.parse,前面的调试输出不会影响解析; - 所有用户可控字符串经
pyQuote()转义。
private pyQuote(value: string): string {
return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n')}'`;
}
这段转义看着朴素,却是访谈文本能安全穿过语言边界的原因:示例访谈里有中文问号、换行、括号,用户导入的资料里可能有单引号和反斜杠;不转义就会在 Python 侧构造出语法错误甚至代码注入。这里也能看出这套"字符串拼代码"方案的边界——它是 MVP 级别的 RPC,正式版应当改成"传参数数组 + 固定函数名"的结构化调用。
6.1 为什么不用 N-API 直接返回 JSON 对象
技术上完全可以 napi_create_object 把结果结构化回传,但当前设计有两个现实优点:
- 调试可见:每个业务动作的原始 Python 回包都进底部日志栏,真机排障时不需要连 hilog 也能看懂(图 0、图 2、图 4 的日志行都是证据);
- 零协议代码:不需要为 9 个领域函数各写一遍参数转换,新增 Python 函数时 ArkTS 只改一行调用字符串。
代价是每次调用都要重新 import(CPython 模块缓存会让成本很低),以及必须严格保证"最后一行是纯 JSON"。这在 runSelfCheck 里通过 MVP_CORE_SUCCESS 标记做了兜底验证。
七、ArkTS 工作台:把 QDA 语义翻译成三栏界面
Index.ets 748 行,是单文件组件。先列状态,再看渲染。
7.1 状态机字段
| 字段 | 类型 | 作用 |
|---|---|---|
runtimeReady / busy / status | boolean/boolean/string | 运行时闸门、并发保护、顶栏状态 |
project | ProjectSummary | null | 左栏统计与项目路径 |
selectedText / selectedName | string | 当前资料全文与文件名 |
selStart / selEnd | number | 选区 [start, end),-1 表示无 |
codeEntries / codings / currentCodings | 数组 | 全部代码 / 全部编码 / 当前资料的编码 |
pickedCode / newCode | string | 选中的已有代码 / 输入框里的新代码 |
viewMode | boolean | true=查看高亮,false=选取编码 |
showAbout | boolean | 关于弹窗 |
logLines | string[] | 最多保留 120 条,底栏只显示最后一条 |
build() 是 Stack 套 Column:48px 顶栏 + 三栏 Row + 28px 日志栏,最上层按需叠 AboutDialog()。三栏比例 24% / layoutWeight(1) / 30%,正好对应"项目 → 原文 → 代码本"的研究动线。
7.2 两种模式,两套渲染组件
这是 UI 层最关键的设计。查看模式用 Text() { ForEach(Span(...)) } 做富文本高亮;选取模式用 TextArea 拿系统级文本选区。两者互斥,由顶部 Toggle 切换:
Row() {
Text('编码工作区').fontSize(18).fontWeight(FontWeight.Bold).layoutWeight(1)
if (this.selectedName.length > 0) { Text(this.selectedName) ... }
Text(this.viewMode ? '查看模式 · 高亮显示已编码区间'
: '选取模式 · 拖选文字后点「编码选中文字」')
Toggle({ type: ToggleType.Switch, isOn: !this.viewMode })
.onChange((isOn: boolean) => {
this.viewMode = !isOn;
this.selStart = -1;
this.selEnd = -1;
})
}
为什么要分开?因为 ArkUI 的 Span 适合渲染带背景色的只读文本,而 TextArea 才提供 onTextSelectionChange(start, end) 这个精确选区回调。硬把两者揉进一个组件,会同时失去高亮和选区。
7.3 分块算法:把编码区间变成 TextChunk[]
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; } // 重叠区间:先到先得
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;
}
四层防御:
- 过滤越界:资料被替换后旧编码的
pos1可能超过新文本长度,直接丢弃,避免substring抛错; - 按起点升序、同起点按长度降序:长区间优先;
- 重叠跳过:
span.pos0 < cursor就跳过,保证渲染不重复不嵌套; - 颜色哈希预计算:
colorForCode()把代码名散列到 6 色调色板,TextChunk里直接带color和bold字段。
第 4 点来自一次真实的静默失败:ArkUI 属性链里调用私有方法(如 .backgroundColor(this.colorForCode(...)))会被忽略,而 Span 根本没有可生效的 backgroundColor。最终正确写法是预计算字段 + textBackgroundStyle:
Text() {
ForEach(buildChunks(this.selectedText, this.currentCodings), (chunk: TextChunk) => {
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).lineHeight(24).width('100%')
点击高亮块还会把代码名回显到状态栏,等于"点一下就知道这段贴了什么标签"。
7.4 选区:一个非常隐蔽的失焦陷阱
TextArea({ text: this.selectedText })
.onTextSelectionChange((start: number, end: number) => {
if (end > start) {
this.selStart = start;
this.selEnd = end;
}
})
if (end > start) 不是防御性冗余。TextArea 在失焦、点击别处、切换模式时会回调 start == end 的"空选区",如果无条件写回状态,用户刚拖好的选区会在点「编码选中文字」按钮的瞬间被清空,表现为"按钮一直是灰的"。只在 end > start 时更新,选区就能跨焦点保留。
底部按钮与提示也完全由选区驱动:
Text(this.selEnd > this.selStart
? `选中 [${this.selStart}, ${this.selEnd})`
: '在上方文本中选中一段文字')
Button('编码选中文字').enabled(this.selEnd > this.selStart)
.onClick(() => this.codeSelection())
图 4 底部提示里的 选中 [9, 17),就是这个字段的实时回显。
7.5 编码动作:一次调用,四处刷新
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 result = this.runJson(/* code_selection(...,pos0, pos1) */);
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.substring(0, 20)}…`;
}
}
refreshCodes() 是全局同步点,一次做四件事:list_codes 刷代码表、list_codings 刷记录、按 selectedName 过滤出 currentCodings 供高亮、summary 刷左栏统计。启动时、载入示例后、增删代码后、编码/撤回/清空后都会调用它,这保证了重启应用后代码本和编码记录不会为空。
7.6 文件选择:零权限 + 编码兜底
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);
const source = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY);
const count = fileIo.readSync(source.fd, buffer);
this.selectedText = decodeText(new Uint8Array(buffer, 0, count));
两个 API 级细节:
fileIo.openSync()返回File对象,statSync/readSync/closeSync要传的是file.fd,不是 File 本身;util.TextDecoder.create('utf-8').decodeToString()需要Uint8Array,不是ArrayBuffer。
decodeText() 还做了中文场景很实用的兜底:UTF-8 解出 \uFFFD 就改用 GB18030 重解,避免国内常见的 GBK 访谈稿变成乱码:
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;
}
7.7 示例访谈:一条可复现的验收数据
loadSampleText() 内置了一段 2026 年 10 月的教师访谈(12 个问答轮次,涉及表格、打卡、留痕、层层加码、周末补备课等主题)。它不是装饰文本,而是端到端验收夹具:文本里有多个天然可编码片段,例如"以前晚上能安安静静备课改作业,现在一半时间在应付检查材料"可标 工作负荷,"希望真正给教师减负,让检查归检查、教学归教学"可标 减负诉求。每次演示、每次回归都用同一份文本,截图之间才有可比性。
八、构建与出包:可复现命令
$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 'D:\work\ohos_QualCoder' # 换成你本地工程的实际路径
python tools\build_runtime.py # 改过 ohos_mvp.py 才需要
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
产物:
ohos_hap/entry/build/default/outputs/default/
├── entry-default-unsigned.hap 38.12 MB
└── entry-default-signed.hap 38.78 MB
几个环境事实(都踩过):
| 现象 | 原因 | 处理 |
|---|---|---|
spawn java ENOENT / 打包阶段失败 | app_packing_tool.jar 需要 java | 把 DevEco 的 jbr\bin 前置到 PATH,或设 JAVA_HOME |
DEVECO_SDK_HOME 未设导致 SDK 找不到 | 命令行构建不读 IDE 配置 | 显式设为 DevEco sdk 目录 |
换机后 hdc install 报签名/trust 错 | p7b profile 与 bundleName 绑定 | 在 DevEco 里对 org.qualcoder.native 重新自动签名一次 |
| 改了 Python 代码但设备没变化 | 复用了已解压的旧 ohos_python | 递增 RUNTIME_MARKER 后重编 |
体积拆解(帮助判断包体是否健康):
| 组成 | 体积 | 说明 |
|---|---|---|
libpython3.12.a | 33.32 MB | 构建期静态库,链接后被裁剪进 libqualcoder_native.so |
ohos_python_stdlib.zip | 4.39 MB | 标准库纯 Python 部分 + qualcoder 包,压缩存放 |
54 个 C 扩展 .so | 随 HAP libs | 由 collectAllLibs 收包 |
| 签名后 HAP | 38.78 MB | 相比 Electron/Qt 整包动辄数百 MB,属于轻量档 |
九、真机动线:五张截图串起一次完整编码
下面按实际点击顺序还原。所有图都是 HarmonyOS PC 真机截图,未做后期拼接。
步骤 1:桌面启动,内核自检

图 1:桌面出现「质码工坊(from QualCoder)」图标;打开后是三栏空工作台。左栏已自动创建「MVP 项目」,路径为 /data/storage/el2/base/haps/entry/files/QualCoderProjects/mvp.qda,统计为「资料 0 · 代码 0 · 编码 0」;右栏代码表为空;底部日志显示 QualCoder core ready——这是 runTest() 在真机上通过的信号。
这一步同时验证了四件事:HAP 安装成功、Ability 生命周期正常、rawfile 运行时解压成功、CPython 自检通过。顶部状态栏的"运行时就绪"来自 prepareRuntime() 的最后一环。
步骤 2:载入示例访谈

图 2:点击「载入示例访谈」后,中栏出现 sample_interview.txt 全文,右上角切换开关处于查看模式,底部显示「当前资料已编码 0 段 · 分块 1」。日志行是 summary() 返回的 JSON:sources: 0, codes: 0, codings: 0, storage: "manifest"。
为什么"资料 0"?因为此时只是把文本放进内存供阅读和选取,还没有点「导入当前资料」登记进项目。这刻意区分了浏览与入库两个动作,也对应 import_text() 的语义。
步骤 3:切到选取模式

图 3:打开右上角开关,中栏从 Span 富文本切换为 TextArea,提示文字变成「选取模式 · 拖选文字后点『编码选中文字』」。此时尚未产生选区,底部提示「在上方文本中选中一段文字」,蓝色按钮灰置——正是 enabled(this.selEnd > this.selStart) 的直观表现。
步骤 4:选中文本并编码
在 TextArea 里拖选一句访谈原文,onTextSelectionChange 回传选区端点,底部提示随即变成 选中 [pos0, pos1) 的形式(下一张图里可以看到 选中 [9, 17) 的实例);在右栏输入或选中代码 go,点击「编码选中文字」。code_selection() 落盘后,界面自动切回查看模式,效果即文首图 0:原文对应片段蓝底加粗,代码表出现 go 且计数为 1,编码记录显示 go · sample_interview.txt 与摘录文本,底部变为「当前资料已编码 1 段 · 分块 3」,日志回显 summary() 的 JSON。
"分块 3"是 buildChunks() 的直接证据:一个编码区间把全文切成了"未编码前缀 + 高亮块 + 未编码后缀"三段。
步骤 5:多代码、撤回与关于

图 4:继续添加 po、popppp 后,右栏代码表显示三条代码及各自计数(go: 1、po: 1、popppp: 0),编码记录里可见两条不同代码的摘录,均可点 × 撤回。底部 选中 [9, 17) 说明又进入了一次选取模式;日志行是 add_code 返回的 {ok: true, name: "popppp", "codes": [...]}。中间「关于」弹窗写明首版适配范围:内嵌 CPython 3.12、沙箱项目目录、文本资料导入与 SQLite 兼容核心,并明确音视频、报告和 AI 能力按后续阶段接入。
截图可验证的字段对照
| 截图元素 | 代码来源 | 说明 |
|---|---|---|
QualCoder core ready | RunTest() 的 print | 内核自检通过 |
storage: "manifest" | summary() / _create_sqlite_project() | 当前缺 _sqlite3,走 JSON 清单 |
资料 0 · 代码 0 · 编码 0 | ProjectSummary | 左栏统计由 refreshCodes() 刷新 |
sample_interview.txt | loadSampleText() | 内置验收夹具 |
选中 [9, 17) | selStart/selEnd | TextArea 选区回调 |
| 蓝色高亮块 | buildChunks() + textBackgroundStyle | Span 富文本渲染 |
分块 3 | buildChunks(...).length | 区间切分结果 |
go: 1 | list_codes() 的 count | 代码使用次数 |
| 编码记录 × | deleteCoding() | 按 (code,source,pos0,pos1) 撤回 |
| 日志 JSON | runJson() → log() | Python 回包原样回显 |
十、排障实录:十二个真实踩过的坑
这一章按"现象 → 根因 → 修复 → 验证"记录,全部来自本次真机调试,不是通用 API 注意事项的堆砌。
坑 1:添加代码直接杀进程(最严重)
- 现象:在右栏输入代码名点「添加」,应用瞬间退出,日志里出现
data source dose not have forEach function,错误码103806。 - 根因:Python 的
add_code()返回的是 dict{ok, name, codes},早期 ArkTS 把整个JSON.parse结果赋给了@State codeEntries: CodeEntry[]。ForEach拿到对象后没有迭代能力,ArkUI 直接抛运行时错误终止渲染树。 - 修复:取
.codes字段,并用Array.isArray做类型守卫;所有"JSON → 数组状态"的赋值点统一加保护。
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}`);
}
refreshCodes() 里 list_codes / list_codings / 当前资料编码三处、以及 deleteCode() 里的返回处理,都按同一模式补齐。教训:跨语言 RPC 的返回类型必须在两端写进类型定义(CodeOpResult / CodingResult),并且任何一次 JSON.parse 都不能无条件信任。
- 验证:连续添加
go/po/popppp三条代码,进程稳定,图 4 的日志行正是add_code返回的 dict 原文。
坑 2:Span 的背景色怎么都画不出来
- 现象:编码落盘成功、分块数据正确,但中栏文字没有任何高亮。
- 根因:ArkUI 的
Span不是普通容器组件,.backgroundColor()在它身上会被静默忽略;不报错、不警告。 - 修复:改用
Span专有的.textBackgroundStyle({ color, radius })。
Span(chunk.text).textBackgroundStyle({ color: chunk.color, radius: 4 })
- 验证:图 0 里蓝底加粗的编码片段。
坑 3:属性链里调方法,代码"看起来对了"却不生效
- 现象:把颜色函数写成
.textBackgroundStyle({ color: this.colorForCode(chunk.code) })后,高亮依旧不出现。 - 根因:ArkUI 声明式属性链里直接调用私有方法,在部分版本会被静默忽略(同类坑还包括
.width(this.calcW()))。 - 修复:把"计算"前移到纯函数
buildChunks(),TextChunk预置color/bold,属性链只读字段。
interface TextChunk { text: string; code: string; color: string; bold: boolean }
// buildChunks 时算好 color: colorForCode(span.code), bold: true
// 渲染时只读 chunk.color / chunk.bold
附带收益:buildChunks 变成不依赖组件实例的纯函数,边界条件可以单独推理。
坑 4:一点按钮,选区就没了
- 现象:拖选文字后,「编码选中文字」明明亮了,点击却提示"请先选中一段文字"。
- 根因:点击按钮导致
TextArea失焦,触发一次onTextSelectionChange(start == end),把selStart/selEnd重置成同一个值。 - 修复:只在
end > start时更新状态,允许选区跨焦点保留(见 7.4 节代码)。 - 验证:图 4 底部
选中 [9, 17)与右下角已点亮的蓝色按钮同时存在。
坑 5:重启后代码本和编码记录全空
- 现象:编码完成后杀掉应用重开,数据其实还在 JSON 清单里,但右栏一片空白。
- 根因:界面只在"操作成功后"刷新,启动时没有从 Python 内核拉一次全量状态。
- 修复:把
refreshCodes()提到prepareRuntime()成功分支里(紧跟ensureProject()),并在loadSampleText()末尾再调一次以按文件名过滤currentCodings。 - 验证:多次增删后,图 4 中的
go/po计数与两条编码记录始终保持同步;重启后的持久化另由第 11.1 节验证项覆盖。
坑 6:C 扩展 dlopen 失败,标准库"半残"
- 现象:
os.py能读、Py_InitializeEx成功,但一import math就报错。 - 根因:解压到 el2 的
.so受链接器命名空间限制不能dlopen;同时静态整链的解释器符号没有默认进入全局符号域。 - 修复:三件事一起做——①
build_runtime.py从 zip 删掉lib-dynload/*.so;② 54 个扩展改放entry/libs/arm64-v8a并开collectAllLibs;③ C++ 用dladdr推导 libs 路径、RTLD_NOLOAD|RTLD_GLOBAL提升宿主符号,CMake 加--export-dynamic、strip: false。 - 验证:
runTest()能在真机跑通(内部用到json、datetime、pathlib、tempfile等模块,其中若干依赖 C 扩展)。
坑 7:sqlite3 导入失败,但不是崩溃
- 现象:
summary()返回"storage": "manifest",项目目录里没有data.qda。 - 根因:扩展集里没有
_sqlite3.cpython-312-aarch64-linux-ohos.so。 - 处理:把它当成受控降级而不是异常——
try: import sqlite3失败即置None,所有读写走qualcoder_mvp.json,并在返回的storage字段里如实上报。 - 价值:P2.5 只要补一个
.so,就能切回与桌面版互通的data.qda,业务层零改动。
坑 8:改了 Python 代码,设备上永远不生效
- 现象:修改
ohos_mvp.py、重新出包安装,界面行为还是旧的。 - 根因:
prepareRuntime()看到os.py与 marker 都在,就复用了上次解压的filesDir/ohos_python,而 HAP 更新并不会自动清 el2 数据。 - 修复:版本化 marker 常量,改运行时就递增;同时先跑
build_runtime.py重建 zip。
const RUNTIME_MARKER = 'READY_QUALCODER_P1_0_0_2'; // 改 zip 内容时递增
坑 9:访谈文本里有单引号/换行,Python 直接语法错
- 现象:导入某些 txt 后,日志出现
SyntaxError,JSON 解析失败。 - 根因:ArkTS 用模板字符串拼 Python 源码,用户文本中的
'、\、\n会破坏字面量。 - 修复:
pyQuote()统一转义三件套;所有用户输入(文件名、代码名、选中文本、资料全文)都经过它。 - 延伸:更严谨的做法是把
runCode换成callFunction(name, argsJson),由 C++ 侧解析参数数组,彻底避免"代码拼接"。
坑 10:文件读取的 API 细节
| 写法 | 问题 | 正确 |
|---|---|---|
fileIo.statSync(file) | openSync 返回的是 File 对象,需要 .fd | fileIo.statSync(file.fd) |
TextDecoder.decode(buffer) | 需要 Uint8Array | decodeToString(new Uint8Array(buffer, 0, count)) |
| 只读 UTF-8 | 国内 GBK 访谈稿乱码 | 检出 \uFFFD 后改用 gb18030 重解 |
假设 uri 是路径 | picker 返回 file:// 形式 | 用 fileIo.openSync(uri, READ_ONLY),文件名从 URI 末段 decodeURIComponent |
坑 11:只看 PyRun_SimpleString 返回值等于没看
PyRun_SimpleString 只给一个 int rc,Python 侧的具体异常在 sys.stderr。因此 RunPythonCode() 把 stdout / stderr 分别重定向到 py_capture_out.txt / py_capture_err.txt,模板里包 try/except traceback.print_exc(),C++ 读完拼成带 --- stdout --- / --- stderr --- 分隔的报告。真机上任何一次"业务没反应",底部日志栏都能看到完整 Python 栈,这是本次排障效率最高的一个设计。
坑 12:命令行构建与签名的环境债
| 报错 | 根因 | 处理 |
|---|---|---|
spawn java ENOENT | HAP 打包工具是 jar | DevEco jbr\bin 前置 PATH / 设 JAVA_HOME |
| SDK 找不到 | 命令行不继承 IDE 配置 | 显式 DEVECO_SDK_HOME |
| 安装报 trust / profile 错 | p7b 与 bundleName 绑定 | 对 org.qualcoder.native 重新自动签名 |
compatibleSdkVersion 被自动签名改写 | DevEco 可能升级到 beta SDK | 固定回 6.0.1(21) |
坑清单速查
| # | 关键词 | 层级 | 后果 |
|---|---|---|---|
| 1 | dict 当数组 / 103806 | ArkTS ↔ Python | 进程退出 |
| 2 | Span.backgroundColor | ArkUI | 高亮静默失效 |
| 3 | 属性链方法调用 | ArkUI | 属性静默失效 |
| 4 | start == end 回调 | ArkUI 事件 | 选区被清空 |
| 5 | 启动未刷新 | 状态管理 | 重启后列表空白 |
| 6 | el2 dlopen | 链接器 | C 扩展不可用 |
| 7 | _sqlite3 缺失 | 运行时裁剪 | 降级 manifest |
| 8 | marker 未递增 | 部署 | 旧代码一直生效 |
| 9 | 字符串拼 Python | 跨语言 | 语法错 / 注入面 |
| 10 | .fd / Uint8Array | API | 读文件失败 |
| 11 | 只取 rc | 可观测性 | 无法定位异常 |
| 12 | JAVA_HOME / 签名 | 工具链 | 出包或安装失败 |
十一、验证矩阵:不靠"能打开"就算完成
11.1 端到端功能验证
| # | 操作 | 预期 | 证据 |
|---|---|---|---|
| 1 | 首次启动 | 解压运行时、QualCoder core ready、自动建 MVP 项目 | 图 1 |
| 2 | 「载入示例访谈」 | 中栏显示全文、底部「0 段 · 分块 1」 | 图 2 |
| 3 | 切换选取模式 | TextArea 接管、按钮灰置 | 图 3 |
| 4 | 拖选 + 选代码 + 编码 | 高亮块、代码计数 1、编码记录 1 条 | 图 0 |
| 5 | 添加多个代码 | 三条代码、各自计数正确 | 图 4 |
| 6 | 点编码记录 × | 记录与高亮同步消失 | deleteCoding + refreshCodes |
| 7 | 「清空本资料编码」 | 当前资料编码归零,其他资料不受影响 | list_codings(path, '', source) 过滤 |
| 8 | 删除代码 | 该代码所有编码级联删除 | delete_code 双通道 |
| 9 | 重启应用 | 代码本、编码记录、统计全部恢复 | prepareRuntime → refreshCodes |
| 10 | 「载入文本资料」选 GBK txt | 中文正常、无 \uFFFD | decodeText |
11.2 边界与异常用例
| 用例 | 期望行为 | 实现位置 |
|---|---|---|
| 空代码名点添加 | 直接 return,不调用内核 | addCode() |
| 未选中文字点编码 | 状态栏「请先在资料中选中一段文字」 | codeSelection() |
| 代码与选区都为空 | 状态栏「请先选择或输入代码」 | codeSelection() |
pos0 < 0 / pos1 <= pos0 | Python 抛 ValueError | code_selection() |
| 项目目录不存在 | Python 抛 FileNotFoundError | 各领域函数入口 |
| 同一区间重复编码 | 覆盖而非重复插入 | codings[:] = [过滤同四元组] + SQL INSERT OR REPLACE |
| 同名资料重复导入 | 覆盖文件内容与登记 | manifest 去重 + ON CONFLICT DO UPDATE |
| 编码区间越界(换了资料) | 渲染时过滤掉,不崩 | buildChunks().filter() |
| 重叠编码区间 | 长区间优先、后续跳过 | sort + cursor |
| JSON 返回非数组 | 记日志、保留旧状态 | Array.isArray 守卫 |
| Python 异常 | 日志栏显示 traceback,status 提示失败 | _Capture + parseReport |
11.3 数据落盘检查点
filesDir/
├── ohos_python/ # 解压后的 CPython stdlib
│ ├── .READY_QUALCODER_P1_0_0_2 # 版本 marker
│ └── lib/python3.12/
│ ├── os.py
│ └── site-packages/qualcoder/{__init__.py, ohos_mvp.py, QUALCODER_OHOS_MVP}
├── ohos_python_stdlib.zip # 从 rawfile 拷出的部署源
├── py_capture_out.txt / py_capture_err.txt # 每次 runCode 覆盖写
└── QualCoderProjects/mvp.qda/ # 项目目录
├── documents/ # 导入的资料原文
├── images/ audio/ video/ # 预留媒体目录
└── qualcoder_mvp.json # 当前实际存储
真机截图里那串 /data/storage/el2/base/haps/entry/files/... 与这里的 filesDir 完全对应,说明数据确实落在应用自己的沙箱,而不是写到了全局路径。
十二、性能、安全与隐私
12.1 性能特征
| 维度 | 设计 | 影响 |
|---|---|---|
| 冷启动解压 | 只在 os.py 或 marker 缺失时执行 | 首次安装后有一次性开销,之后复用 |
| 解释器 | Py_InitializeEx(0) 单例常驻 | 每次业务调用无重启成本 |
| 并发 | std::mutex g_pyMu 串行化 | 简单可靠;后续高频调用可改任务队列 |
| 业务往返 | 每次操作 1 次 runCode + refreshCodes 内 4 次 | 单次交互 5 次进程内 Python 调用,实测无感知延迟 |
| 渲染 | buildChunks 只处理当前资料的编码 | 与项目总编码量无关 |
| 包体 | 38.78 MB signed HAP | 无 Electron / Qt 运行时拖累 |
| 字节码 | PYTHONDONTWRITEBYTECODE=1 | 不在 el2 写 __pycache__ |
12.2 安全边界
- 零权限:
requestPermissions: [],不申请网络、存储、麦克风等权限。 - 用户授权制读取:外部资料只能经
DocumentViewPicker由用户显式选择;应用自身写死在filesDir。 - 不联网:内核没有 AI/ telemetry / 更新检查;
_ssl未进入运行时,也没有网络权限。 - 输入转义:代码名、文件名、资料全文、选中文本全部经
pyQuote()后进入 Python 字面量。 - 可观测但不外泄:
logLines只在内存保留最近 120 条,底栏仅显示最后一条;py_capture_*每次覆盖写且位于私有沙箱。 - 入口面小:
module.json5只声明entity.system.home/action.system.home,没有额外 URI scheme 或对外 IPC 暴露runCode。
需要诚实指出的两点:
runCode本质是"执行任意 Python 字符串"。当前它只被应用内部代码调用,且所有用户输入已转义,风险可控;但作为长期架构,应改成callFunction(name, argsJson),把"函数名白名单 + 参数结构化"下沉到 C++,从根上消除拼接面。qualcoder_mvp.json是明文。访谈资料可能含敏感信息,本地存储未加密。若面向真实研究场景,需要补沙箱文件加密或系统密钥托管。
12.3 签名与凭据
签名材料(p12、p7b、加密口令)属于本机个人环境,不写进文章、也不应提交到公开仓库。发布源码时应清空 build-profile.json5 的 signingConfigs,并在 .gitignore 里排除 *.p12、*.p7b、*.cer、build/、.hvigor/ 等。换机编译由 DevEco 按 bundleName 重新自动签名。
十三、与上游的差距、已知问题与路线图
13.1 能力对照
| 能力 | 上游 QualCoder | 质码工坊当前版 |
|---|---|---|
| 项目 / 资料 / 代码 / 编码 四层模型 | ✅ | ✅(语义对齐,单项目) |
| 文本区间编码与高亮 | ✅ | ✅ |
| 代码增删、级联删除、编码撤回 | ✅ | ✅ |
data.qda SQLite 项目文件 | ✅ | ⚠️ 代码就绪,设备端因缺 _sqlite3 走 manifest |
| docx / PDF / epub / odt 导入 | ✅ | ❌(仅 txt/md/csv/html/tex/rtf 经 picker 读取) |
| 图片 / 音视频编码 | ✅ | ❌(目录已预留) |
| 代码分类层级、多编码者比较 | ✅ | ❌(schema 有 code_cat / owner 字段,UI 未开放) |
| 报告、编码频次、可视化图谱 | ✅ | ❌ |
| AI 辅助分析 | ✅ | ❌ |
| 桌面 GUI(PyQt6) | ✅ | 换成 ArkTS 原生三栏 |
13.2 已知问题(不藏)
| # | 问题 | 影响 | 建议修复 |
|---|---|---|---|
| 1 | add_code() 的 SQLite 分支直接 INSERT,重复代码会触发 UNIQUE 约束;docstring 声称幂等,实际只在 manifest 分支幂等 | 当前设备走 manifest 不暴露;补齐 _sqlite3 后会显形 | 改 INSERT OR IGNORE 并先查存在 |
| 2 | 选区位置是 ArkTS(UTF-16 code unit)语义,Python 存的是 code point 索引 | 中文 BMP 文本无差异;含 emoji / 代理对时可能与桌面版 .qda 位置不一致 | 统一按 Unicode scalar 计位,或在桥接层做索引换算 |
| 3 | 固定单项目 mvp.qda | 无法新建/切换项目 | 增加项目列表与 DocumentViewPicker 目录选择 |
| 4 | pyQuote 未处理 \r、U+2028 等特殊字符 | 极少数文本可能拼出异常 Python 字面量 | 换结构化 RPC |
| 5 | 代码颜色只有 6 色且用哈希取模 | 代码多时可能撞色 | 用代码表里持久化的 color 字段 |
| 6 | 重叠编码区间渲染"先到先得" | 同一段文字贴两个代码时,只显示第一个的高亮 | 支持多重编码叠加或分层底色 |
| 7 | runCode 每次拼 import json ... | 轻微重复解析开销 | 预置 helper 模块 / 结构化调用 |
| 8 | refreshCodes 一次发 4 条 Python | 逻辑简单但调用数偏多 | 合并为一个 snapshot(path, source) 返回 |
13.3 阶段路线
| 阶段 | 目标 | 关键工作 |
|---|---|---|
| P0/P1(已完成) | 运行时与无头内核 | 静态 CPython、rawfile 解压、NAPI 桥、自检、项目/资料导入 |
| P2(本次补完) | 编码闭环 | 代码 CRUD、区间编码、高亮渲染、撤回、清空、重启持久化 |
| P2.5 | 真 data.qda | 交叉编译 _sqlite3 入 libs,修 add_code 幂等,与桌面版项目互通验证 |
| P3 | 媒体能力 | 图片导入与标注、音频/视频时间段编码(需评估 VLC/ffmpeg 在 OHOS 的可行性) |
| P4 | 工作台完善 | 多项目、代码树/分类、资料树、编码统计报告、主题与快捷键 |
| P5 | AI(可选) | 本地检索/提示词模板;若走云端必须单独声明隐私与权限 |
十四、从零复现:一份可勾的清单
- 准备 DevEco Studio 与 HarmonyOS SDK 6.0.1(21) / 6.0.2(22),确认
DEVECO_SDK_HOME、JAVA_HOME可用。 - 取得工程,确认三份真相源齐全:
Index.ets、ohos_mvp.py、ohos_python_stdlib.zip。 - 确认
cpp/prebuilt/libpython3.12.a(33.32 MB)与cpp/python_inc/(214 个头文件)在位;entry/libs/arm64-v8a有 54 个扩展.so。 python tools/build_runtime.py:从基线 zip 剔除 vendored Thonny 与lib-dynload/*.so,注入qualcoder/__init__.py、ohos_mvp.py、QUALCODER_OHOS_MVP标记。- 若改过运行时内容,递增
Index.ets的RUNTIME_MARKER。 ohpm install --prefix entry,把本地libqualcoder_native.so类型模块挂进依赖。- 用 DevEco 对
org.qualcoder.native自动签名(或填本机签名材料,勿提交)。 hvigorw.bat --mode module -p module=entry@default -p product=default assembleHap --no-daemon。- 安装
entry-default-signed.hap,首次启动看底部日志是否出现QualCoder core ready与MVP_CORE_SUCCESS。 - 按第九章五步动线做真机验收;再按 11.2 边界表抽测 5 项。
- 出图留证:空工作台、示例载入、选取模式、编码高亮、多代码/关于。
一条经验:任何"看起来没反应"的问题,先看底部日志栏。这一版把 Python 回包和 traceback 原样显示出来,绝大多数故障在第一次点击后就能定位,不需要连调试器。
十五、十二条可迁移的经验
- 先定边界再写代码:明确"垂直切片"而不是"完整移植",能避免把 80% 时间耗在 PyQt6 平台适配上。
- 数据模型比界面更值得复用:保住
source/code_name/code_text的语义,将来换 UI、补 SQLite 都不返工。 - 沙箱里"数据"和"代码"要物理分开:stdlib 解压到 el2 当数据读,C 扩展留在 HAP libs 当代码加载。
- 静态整链三件套:
--whole-archive+--export-dynamic+strip: false,缺一个都可能在真机才炸。 - 给运行时加版本 marker:否则"改了代码不生效"会伪装成业务逻辑 bug。
- 跨语言用"最后一行纯 JSON"做最小 RPC:调试可见、扩展便宜;长期要升级成结构化调用。
- 任何
JSON.parse结果都不能直接进ForEach:Array.isArray是保命符。 - ArkUI 属性链只读预计算字段:方法调用、
Span.backgroundColor这类静默失败,比报错更贵。 - 文本选区回调要过滤空选区:失焦会吞掉用户刚拖好的区间。
- 把降级路径写进返回值:
storage: "manifest"让"缺扩展"变成可观测状态,而不是玄学。 - 启动自检要真的建项目、写文件、断言:
MVP_CORE_SUCCESS比"界面能打开"可靠一个数量级。 - 零权限是设计,不是限制:picker 授权 + 私有沙箱,反而让这类研究工具更容易说清隐私边界。
十六、Q&A:关于这次移植最常被问到的问题
整理成 34 组问答,按"选型 → 运行时 → 数据 → 界面 → 排障 → 后续"排列。答案里尽量给出具体文件、常量或章节号,方便按需回查。
16.1 定位与技术选型
Q1:「质码工坊」是 QualCoder 官方鸿蒙版吗?
不是。它是基于上游 QualCoder(LGPL-3.0)数据模型做的一次独立适配实验,应用显示名写作「质码工坊(from QualCoder)」正是为了标明来源、避免与上游混淆。包名 org.qualcoder.native、界面、领域核心都是本次工程自己实现的,与上游维护者无关。
Q2:这算"完整移植"吗?PyQt6 界面去哪了?
不算。本次交付的是垂直切片:项目、资料、代码、编码四类对象加上完整的文本编码闭环(第二章、十三章)。上游的 PyQt6 GUI、音视频编码、PDF/docx 解析、报告图表、AI 分析都还没接。PyQt6 被 ArkTS 原生三栏界面替代,是路线 C 的主动选择,不是漏掉了文件。
Q3:为什么不用 Electron / WebView 套一个网页界面?
包体和运行时都会变大,而且还要处理 Web 组件与原生文件选择、文本选区之间的桥接。QDA 工作台需要的是常驻代码表、精确选区、宽屏信息密度,ArkUI 的 Row/Column/List/Span 已能直接表达;这条路线最后只出一个 38.78 MB 的 HAP。对比第七章的组件设计就能看出,界面层没有引入任何 Web 依赖。
Q4:干脆全部用 ArkTS 重写,不是更彻底吗?
可以更轻,但会丢掉两样东西:一是上游 source/code_name/code_text 的语义,二是后续接入真正 data.qda 与 Python 生态的可能。把领域逻辑留在 341 行 ohos_mvp.py 里,宿主 Python 就能直接跑逻辑验证,补一个 _sqlite3.so 即可切换存储通道,不必重写业务层。
Q5:为什么选择"UI 原生 + Python 内核",而不是把 Python 换成 JS?
因为目标是让真实 CPython 解释器在鸿蒙应用进程里跑起来,而不是模拟业务逻辑。runTest() 会在设备上真的建项目、导入文本、断言统计与落盘,成功后打印 MVP_CORE_SUCCESS;这条链路给后续补 Python 生态留了通路。
16.2 运行时与构建
Q6:为什么不能像桌面那样直接 exec python3 script.py?
HarmonyOS 应用沙箱的 filesDir(el2 数据区)不是可执行文件安装位,也没有系统 Python。工程把 libpython3.12.a(33.32 MB)用 --whole-archive 整链进 libqualcoder_native.so,由 Py_InitializeEx(0) 在应用进程内初始化一次,再用 PyRun_SimpleString 执行代码(第四章 4.1)。
Q7:rawfile/ohos_python_stdlib.zip 为什么不直接在压缩包里用?
CPython 需要从文件系统按路径 import,因此首启要把 4.39 MB 的 zip 解压到 filesDir/ohos_python。prepareRuntime() 以 lib/python3.12/os.py 是否可读作为完整性判据,并写 .READY_QUALCODER_P1_0_0_2 标记;之后每次启动直接复用,不再解压。
Q8:为什么 C 扩展不能一起放进 zip?
el2 解压出来的 .so 受链接器命名空间限制,无法被 dlopen。所以 build_runtime.py 会删掉 zip 里的 lib-dynload/*.so,改由 entry/libs/arm64-v8a 提供 54 个扩展,C++ 侧用 dladdr 推导出宿主 .so 所在目录并追加到 PYTHONPATH(第四章 4.3)。
Q9:--export-dynamic、RTLD_GLOBAL、strip: false 分别是干什么的?
它们解决同一件事:让扩展模块 dlopen 时能解析到宿主 .so 里的 libpython 符号。--whole-archive 把解释器拉进来,--export-dynamic 把符号放进全局动态表,dlopen(self, RTLD_NOLOAD | RTLD_GLOBAL) 提升已加载对象的符号域,strip: false 保证符号不被剥掉。少一个都可能在真机上出现"编译通过但 import 失败"。
Q10:改了 ohos_mvp.py,重新出包后设备没变化,怎么办?
两步都要做:① 运行 python tools\build_runtime.py 把新模块注入 ohos_python_stdlib.zip;② 递增 Index.ets 的 RUNTIME_MARKER。因为 HAP 更新不会清 el2 数据,marker 不变时 prepareRuntime() 会复用旧目录(坑 8)。
Q11:命令行构建报 spawn java ENOENT 或 SDK 找不到?
HAP 打包工具是 jar,需要把 DevEco 的 jbr\bin 前置到 PATH 或设 JAVA_HOME;同时显式设置 DEVECO_SDK_HOME 指向 DevEco sdk 目录。完整命令见第八章。
Q12:换了一台电脑,安装时报签名/trust 相关错误?
签名 p7b profile 与 bundleName 绑定,不能跨工程复用。在新机器的 DevEco Studio 里对 org.qualcoder.native 重新自动签名一次即可;build-profile.json5 里的本机签名材料不要提交到公开仓库(第十二章 12.3)。
16.3 数据与兼容性
Q13:截图日志里 "storage": "manifest" 是失败了吗?
不是,是受控降级。当前 54 个扩展里没有 _sqlite3,import sqlite3 失败后核心层自动改用 qualcoder_mvp.json 清单,并把当前通道名写进 summary() 返回值。它让"缺扩展"成为可见状态,而不是让应用崩溃或静默丢数据(坑 7)。
Q14:怎么才能用上真正的 data.qda?
交叉编译 _sqlite3.cpython-312-aarch64-linux-ohos.so 放进 libs/arm64-v8a,_sqlite_ok() 就会返回 true,读写自动切到 SQL 分支,业务代码不需要改。切回之前建议先修 add_code() SQLite 分支的幂等问题(十三章 13.2 第 1 条)。
Q15:项目目录叫 mvp.qda,里面还有个 data.qda,不冲突吗?
不冲突,是两个层级。工程里 filesDir/QualCoderProjects/mvp.qda 是项目目录,内部有 documents/ images/ audio/ video/ 四个子目录;data.qda 是 SQLite 可用时放在该目录内的数据库文件;当前降级通道写的是同目录下的 qualcoder_mvp.json(第十一章 11.3)。
Q16:现在能直接打开桌面版 QualCoder 的 .qda 文件吗?
暂时不能。SCHEMA 的五张表虽然按上游字段对齐,但设备端还没有 _sqlite3,也没有项目导入/切换 UI。P2.5 补齐扩展后,才有条件做真正的互通验证。
Q17:编码数据存在哪,重启后还在吗?
在应用私有沙箱的 qualcoder_mvp.json 与 documents/ 里,重启后由 prepareRuntime() → refreshCodes() 拉回代码表、编码记录与统计(坑 5、11.1 节)。目前没有导出 UI,备份需要复制项目目录;这也是 P4 要补的能力。
Q18:为什么选区位置可能与桌面版对不上?
ArkTS 的 TextArea / substring 按 UTF-16 code unit 计位,Python 3 的 str 按 Unicode code point 计位。中文 BMP 文本两者一致,含 emoji 或代理对时会偏移。当前闭环内部自洽(存与渲染都由 ArkTS 切分),但与桌面版 .qda 互通前需要做索引换算(13.2 第 2 条)。
Q19:runCode() 能执行任意 Python 字符串,安全吗?
它是应用内部入口,不对外暴露 URI scheme 或 IPC,所有用户输入(文件名、代码名、资料全文、选中文本)都经 pyQuote() 转义 \、'、\n。但"拼代码"本身是 MVP 级做法,长期应改成 callFunction(name, argsJson) 的函数白名单 + 结构化参数调用(12.2、13.2)。
Q20:README 说"P0/P1 完成、P2 起步",文章却讲编码闭环,哪个准?
以代码和真机截图为准:Index.ets 已实现代码增删、区间编码、高亮、撤回与清空,ohos_mvp.py 也有对应的 9 个领域函数。README 是早期阶段说明,尚未同步,属于文档债,建议在下一次提交时更新。
16.4 界面与交互
Q21:为什么"载入示例访谈"后左栏还是"资料 0"?
因为载入只把文本放进内存供阅读与选取,还没有登记进项目。「导入当前资料」才调用 import_text() 写 documents/ 并更新 source/清单。这个区分在 SQLite 通道下尤其重要:code_selection() 会检查资料是否已登记(第五章 5.3、9 章步骤 2)。
Q22:查看模式和选取模式为什么要分成两个组件?
查看模式用 Text() { ForEach(Span(...)) } 渲染带背景色的只读富文本;选取模式用 TextArea 才能拿到 onTextSelectionChange(start, end) 精确选区。硬揉进一个组件会同时失去高亮和选区,所以顶部 Toggle 在两套渲染之间切换(7.2 节)。
Q23:高亮颜色是怎么来的?同一段能贴多个代码吗?
colorForCode() 把代码名哈希到 6 色调色板,buildChunks() 预计算 color/bold 后用 Span.textBackgroundStyle 渲染。区间按起点升序、同起点长区间优先,重叠部分采取"先到先得"跳过;同一段贴多个代码目前不会叠加显示,这是已知限制(13.2 第 6 条)。
Q24:应用为什么一个权限都不申请?
module.json5 的 requestPermissions 是空数组。应用只写自己的 filesDir;读取外部资料时由 DocumentViewPicker 弹系统选择器,用户选谁就读谁。没有网络权限,也就不存在资料外传与 AI 上传的路径(12.2 节)。
16.5 排障与验收
Q25:点「添加」后应用直接退出,报 103806,是什么原因?
add_code() 返回的是 dict {ok, name, codes},若把整个对象赋给 @State codeEntries: CodeEntry[],ForEach 会因 data source dose not have forEach function 终止渲染。修复是取 .codes 并用 Array.isArray 守卫,所有 JSON → 数组状态处同样处理(坑 1)。
Q26:编码成功了,但文字没有高亮,怎么查?
按顺序查三处:① Span 是否用了 textBackgroundStyle 而非 backgroundColor;② 颜色是否由 buildChunks() 预计算成字段,而不是在属性链里调方法;③ refreshCodes() 是否按 selectedName 拉回了 currentCodings。这三者都是"静默失效",不报错(坑 2、3、5)。
Q27:拖选文字后按钮仍是灰的 / 一点就没选区?
TextArea 失焦会回调 start == end 的空选区,若无条件写回状态就会吞掉刚拖好的区间。只在 end > start 时更新 selStart/selEnd 即可(7.4 节、坑 4)。
Q28:导入 GBK 编码的访谈稿变乱码?
decodeText() 先按 UTF-8 解,检测到 \uFFFD 就用 gb18030 重解。若仍异常,先确认文件本身不是 UTF-16 或其它编码;picker 的后缀过滤目前只覆盖 .txt/.md/.csv/.html/.htm/.tex/.rtf(7.6 节)。
Q29:怎么判断"内核真的好了",而不是界面碰巧能点?
看底部日志:runTest() 会在设备临时目录建项目、导入文本、断言 sources == 1 且落盘文件存在,然后打印 QualCoder core ready 与 MVP_CORE_SUCCESS。没有这两个标记,runSelfCheck() 会把状态栏改成"内核初始化异常"(4.5 节、图 1)。
Q30:真机验收最少要做哪几步?
按第九章五步动线走一遍:启动自检 → 载入示例 → 切选取模式 → 选中并编码 → 多代码/关于;再从 11.2 边界表抽测空代码名、未选区、重复编码、删除级联、重启持久化五项。任何一步"没反应",先看底部日志栏里的 Python 回包或 traceback。
16.6 边界、合规与后续
Q31:包体 38.78 MB 主要是什么?还能再小吗?
主要是静态 CPython 解释器、4.39 MB 运行时 zip 与 54 个 C 扩展。可优化的方向包括裁剪用不到的 stdlib 模块、按实际 import 收敛扩展集、压缩调试符号;但 strip: false 在当前静态整链方案下不能动,否则扩展重定位会失败(第八章体积表、坑 6)。
Q32:音视频、报告、AI 什么时候有?
路线图是 P2.5 补 _sqlite3 → P3 媒体能力 → P4 多项目/代码树/统计报告 → P5 AI(可选)。音视频还依赖 VLC/ffmpeg 在 OHOS 的可行性评估;AI 若走云端,必须重新声明隐私边界与网络权限,不能沿用当前"零权限、不联网"的前提(13.3 节)。
Q33:开源发布前有什么必须处理的合规项?
上游副本 _upstream/QualCoder/LICENSE.txt 是 LGPL-3.0,而工程内 entry/oh-package.json5 与 cpp/types/.../oh-package.json5 的 license 字段写的是 MIT。正式公开仓库前要逐项核对许可证声明、保留上游版权与许可证文本、补齐根目录 LICENSE 与第三方组件清单;本文不构成法律意见,建议按上游条款逐条确认。
Q34:想接着做,从哪个改动性价比最高?
推荐顺序:① 补 _sqlite3 并把 add_code 改成 INSERT OR IGNORE,换回真 data.qda;② 把 runCode 升级为结构化 callFunction,收紧跨语言边界;③ 加项目列表与导出,解决"数据只在沙箱里"的问题;④ 再考虑媒体与 AI。前三项都不需要改 UI 架构,风险最低。
还有没覆盖到的问题?最快定位方式是照第十四章复现一次,然后对照第十章的十二坑清单和文末附录里的常量/API 速查表逐项排查。
附录 A:关键常量与路径速查
| 常量 / 路径 | 值 | 所在文件 |
|---|---|---|
bundleName | org.qualcoder.native | AppScope/app.json5 |
| 显示名 | 质码工坊(from QualCoder) | AppScope/resources/base/element/string.json |
RUNTIME_ZIP | ohos_python_stdlib.zip | Index.ets |
RUNTIME_MARKER | READY_QUALCODER_P1_0_0_2 | Index.ets |
BUILD_TAG | qualcoder-native-1.0.0-mvp | Index.ets |
| Python home | filesDir/ohos_python | Index.ets |
| 项目目录 | filesDir/QualCoderProjects/mvp.qda | Index.ets |
| 清单文件 | <项目目录>/qualcoder_mvp.json | ohos_mvp.py |
| 目标数据库 | <项目目录>/data.qda | ohos_mvp.py |
| 输出捕获 | filesDir/py_capture_out.txt / _err.txt | qualcoder_napi.cpp |
| N-API 模块名 | qualcoder_native | qualcoder_napi.cpp |
| 调色板 | 6 色 #ffe08a #b3e0ff #c8f0c8 #f8c8d8 #e0d0f8 #ffd8b0 | Index.ets |
附录 B:N-API 与领域 API 速查
// cpp/types/libqualcoder_native/index.d.ts
interface QualCoderNativeApi {
getBuildInfo(): string;
setPythonHome(home: string): string; // 返回含 ProbeHome 诊断串
runTest(): string; // 内核自检,含 MVP_CORE_SUCCESS
runCode(code: string): string; // 通用 Python 执行,返回 stdout/stderr 报告
}
| Python 领域函数 | 返回 | 界面调用点 |
|---|---|---|
create_project | ProjectSummary | ensureProject() |
import_text | ProjectSummary | importSelected() |
summary | ProjectSummary | refreshCodes() |
add_code / delete_code | {ok, name, codes} | addCode() / deleteCode() |
code_selection | {ok, code, summary} | codeSelection() |
delete_coding | {ok} | deleteCoding() / clearCurrentCodings() |
list_codes | CodeEntry[] | refreshCodes() |
list_codings | CodingEntry[] | refreshCodes() |
附录 C:术语表
| 术语 | 含义 |
|---|---|
| QDA | Qualitative Data Analysis,定性资料分析 |
| 资料(source) | 导入项目的原始文本,如访谈稿、田野笔记 |
| 代码(code) | 研究者给概念起的标签,如 工作负荷、减负诉求 |
| 编码(coding) | 把某个代码贴到资料的一个字符区间 [pos0, pos1) 上 |
| 代码本(codebook) | 项目内全部代码及其层级、备注、颜色的集合 |
data.qda | QualCoder 的 SQLite 项目数据库文件 |
| el2 | 鸿蒙应用私有数据区,filesDir 所在层级 |
| rawfile | HAP 内不参与资源编译、可整块读出的原始文件目录 |
| HAP / HNP | HarmonyOS 应用包 / 原生软件包 |
| marker | 自定义版本标记文件,用来判断解压目录是否需要刷新 |
结语
把 QualCoder 搬上鸿蒙 PC,最后落地的是一个 38.78 MB 的 HAP:界面是 ArkTS 写的,业务是 Python 跑的,数据朝着上游 schema 对齐,权限表是空的。它显然还不是完整的 QualCoder——PyQt6 界面、媒体编码、报告与 AI 都还在路线图上——但它已经能在一台鸿蒙 PC 上完成定性研究最核心的那个动作:读一段话,选中一句话,给它一个概念,并且让这个概念在重启后依然存在。
对移植工作而言,比"跑起来"更有价值的,是这套分层留出的余地:补一个 _sqlite3.so 就能换回真 data.qda,补一个 code_cat 面板就能展开代码层级,把 runCode 换成结构化调用就能收紧安全边界。原生 UI 与 Python 内核各守一层,谁都不必为对方让步。
下次再遇到"Python 桌面软件怎么上鸿蒙"这个问题,希望这篇记录里那十二个坑、五张真机图和一份可勾的复现清单,能少让人熬一个晚上。
更多推荐



所有评论(0)