把 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:编码后的高亮文本、代码计数与编码记录

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


在这里插入图片描述

摘要:这一次到底交付了什么

项目结果
应用名 / 包名质码工坊(from QualCoder) / org.qualcoder.native
应用版本versionName 1.2.0(versionCode 1020000)
目标设备2in1、tablet(HarmonyOS PC 形态)
SDKcompatibleSdkVersion 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 应用沙箱里,这条路径基本不通:

  1. 没有系统 Python,也不能假设能 exec 一个解释器:应用 filesDir 属于 el2 数据区,不是可执行文件安装位。
  2. 没有 X11 / Wayland,也没有现成的 PyQt6 for OHOS wheel:即使把 Qt 交叉编译过来,Qt 平台插件、字体、剪贴板、拖放、原生对话框仍是一整套工程。
  3. dlopen 有链接器命名空间限制:解压到 el2 的 .so 不能当扩展模块加载。
  4. 沙箱权限模型不同:桌面软件默认"读整块磁盘",鸿蒙应用默认"只能读自己的沙箱 + 用户通过 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 分层架构

存储层 (应用沙箱)

拼 Python 片段 · JSON 单行协议

PyRun_SimpleString

--- stdout --- 文本

CPython 3.12 运行时层

静态 libpython3.12.a
--whole-archive

rawfile zip → filesDir/ohos_python
stdlib + site-packages/qualcoder

54 个 C 扩展 .so
HAP libs/arm64-v8a

N-API 桥接层 (libqualcoder_native.so)

setPythonHome(home)

runCode(code) / runTest()

Py_InitializeEx 单例 + mutex

stdout/stderr 文件捕获 → report

ArkTS / ArkUI 层

项目面板
载入 / 导入 / 统计

编码工作区
查看模式 Span / 选取模式 TextArea

代码面板
代码表 / 编码记录

状态与日志栏
runJson 输出回显

QualCoderProjects/mvp.qda/
documents / images / audio / video

data.qda (SQLite,扩展就绪时)

qualcoder_mvp.json (当前回退)

四层的职责边界:

层关键文件职责不负责
UIets/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 行鸿蒙精简领域核心

三份"真相源"必须分清,否则改错地方会浪费一整晚:

  1. 界面真相源:ohos_hap/entry/src/main/ets/pages/Index.ets
  2. 业务真相源:_upstream/QualCoder/src/qualcoder/ohos_mvp.py
  3. 运行时产物: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);
}

这里面有四个防御性设计:

  1. 以 os.py 是否可读作为"stdlib 完整"的判据,比判断目录存在可靠得多;
  2. .READY_QUALCODER_P1_0_0_2 版本标记:改了 Python 业务代码后,只要递增常量,设备就会重新解压,不需要手动清数据;
  3. setPythonHome 返回诊断串而不是布尔值:C++ 侧 ProbeHome() 会回传 home_access=y home_dir=y lib_dir=y os.py=y home_ls=[...],真机上一眼看出是路径错、解压失败还是缺文件;
  4. 失败即时抛错并写日志栏,不会带着坏运行时继续进业务。

成功链路:

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))",
)

对应关系:

概念表关键字段界面落点
项目projectabout(项目名)、databaseversion左栏「MVP 项目」
资料sourcename 唯一、fulltext、mediapath左栏「资料 N」、中栏文件名
代码分类code_catsupercatid hierarchical暂未开放 UI
代码code_namename 唯一、color、owner右栏代码列表 + 计数
编码code_textpos0 / 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;
  ...
}

约定只有四条:

  1. 每次调用都 import json,最后一行 print(json.dumps(..., ensure_ascii=False));
  2. ensure_ascii=False 保证中文不变成 \uXXXX,日志栏能直接读;
  3. ArkTS 只取 stdout 的最后一行给 JSON.parse,前面的调试输出不会影响解析;
  4. 所有用户可控字符串经 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 / statusboolean/boolean/string运行时闸门、并发保护、顶栏状态
projectProjectSummary | null左栏统计与项目路径
selectedText / selectedNamestring当前资料全文与文件名
selStart / selEndnumber选区 [start, end),-1 表示无
codeEntries / codings / currentCodings数组全部代码 / 全部编码 / 当前资料的编码
pickedCode / newCodestring选中的已有代码 / 输入框里的新代码
viewModebooleantrue=查看高亮,false=选取编码
showAboutboolean关于弹窗
logLinesstring[]最多保留 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;
}

四层防御:

  1. 过滤越界:资料被替换后旧编码的 pos1 可能超过新文本长度,直接丢弃,避免 substring 抛错;
  2. 按起点升序、同起点按长度降序:长区间优先;
  3. 重叠跳过:span.pos0 < cursor 就跳过,保证渲染不重复不嵌套;
  4. 颜色哈希预计算: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.a33.32 MB构建期静态库,链接后被裁剪进 libqualcoder_native.so
ohos_python_stdlib.zip4.39 MB标准库纯 Python 部分 + qualcoder 包,压缩存放
54 个 C 扩展 .so随 HAP libs由 collectAllLibs 收包
签名后 HAP38.78 MB相比 Electron/Qt 整包动辄数百 MB,属于轻量档

九、真机动线:五张截图串起一次完整编码

下面按实际点击顺序还原。所有图都是 HarmonyOS PC 真机截图,未做后期拼接。

步骤 1:桌面启动,内核自检

图 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:载入示例后的查看模式

图 2:点击「载入示例访谈」后,中栏出现 sample_interview.txt 全文,右上角切换开关处于查看模式,底部显示「当前资料已编码 0 段 · 分块 1」。日志行是 summary() 返回的 JSON:sources: 0, codes: 0, codings: 0, storage: "manifest"。

为什么"资料 0"?因为此时只是把文本放进内存供阅读和选取,还没有点「导入当前资料」登记进项目。这刻意区分了浏览与入库两个动作,也对应 import_text() 的语义。

步骤 3:切到选取模式

图 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:多代码、选区与关于弹窗

图 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 readyRunTest() 的 print内核自检通过
storage: "manifest"summary() / _create_sqlite_project()当前缺 _sqlite3,走 JSON 清单
资料 0 · 代码 0 · 编码 0ProjectSummary左栏统计由 refreshCodes() 刷新
sample_interview.txtloadSampleText()内置验收夹具
选中 [9, 17)selStart/selEndTextArea 选区回调
蓝色高亮块buildChunks() + textBackgroundStyleSpan 富文本渲染
分块 3buildChunks(...).length区间切分结果
go: 1list_codes() 的 count代码使用次数
编码记录 ×deleteCoding()按 (code,source,pos0,pos1) 撤回
日志 JSONrunJson() → 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 对象,需要 .fdfileIo.statSync(file.fd)
TextDecoder.decode(buffer)需要 Uint8ArraydecodeToString(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 ENOENTHAP 打包工具是 jarDevEco 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)

坑清单速查

#关键词层级后果
1dict 当数组 / 103806ArkTS ↔ Python进程退出
2Span.backgroundColorArkUI高亮静默失效
3属性链方法调用ArkUI属性静默失效
4start == end 回调ArkUI 事件选区被清空
5启动未刷新状态管理重启后列表空白
6el2 dlopen链接器C 扩展不可用
7_sqlite3 缺失运行时裁剪降级 manifest
8marker 未递增部署旧代码一直生效
9字符串拼 Python跨语言语法错 / 注入面
10.fd / Uint8ArrayAPI读文件失败
11只取 rc可观测性无法定位异常
12JAVA_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中文正常、无 \uFFFDdecodeText

11.2 边界与异常用例

用例期望行为实现位置
空代码名点添加直接 return,不调用内核addCode()
未选中文字点编码状态栏「请先在资料中选中一段文字」codeSelection()
代码与选区都为空状态栏「请先选择或输入代码」codeSelection()
pos0 < 0 / pos1 <= pos0Python 抛 ValueErrorcode_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。

需要诚实指出的两点:

  1. runCode 本质是"执行任意 Python 字符串"。当前它只被应用内部代码调用,且所有用户输入已转义,风险可控;但作为长期架构,应改成 callFunction(name, argsJson),把"函数名白名单 + 参数结构化"下沉到 C++,从根上消除拼接面。
  2. 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 已知问题(不藏)

#问题影响建议修复
1add_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 目录选择
4pyQuote 未处理 \r、U+2028 等特殊字符极少数文本可能拼出异常 Python 字面量换结构化 RPC
5代码颜色只有 6 色且用哈希取模代码多时可能撞色用代码表里持久化的 color 字段
6重叠编码区间渲染"先到先得"同一段文字贴两个代码时,只显示第一个的高亮支持多重编码叠加或分层底色
7runCode 每次拼 import json ...轻微重复解析开销预置 helper 模块 / 结构化调用
8refreshCodes 一次发 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工作台完善多项目、代码树/分类、资料树、编码统计报告、主题与快捷键
P5AI(可选)本地检索/提示词模板;若走云端必须单独声明隐私与权限

十四、从零复现:一份可勾的清单

  1. 准备 DevEco Studio 与 HarmonyOS SDK 6.0.1(21) / 6.0.2(22),确认 DEVECO_SDK_HOME、JAVA_HOME 可用。
  2. 取得工程,确认三份真相源齐全:Index.ets、ohos_mvp.py、ohos_python_stdlib.zip。
  3. 确认 cpp/prebuilt/libpython3.12.a(33.32 MB)与 cpp/python_inc/(214 个头文件)在位;entry/libs/arm64-v8a 有 54 个扩展 .so。
  4. python tools/build_runtime.py:从基线 zip 剔除 vendored Thonny 与 lib-dynload/*.so,注入 qualcoder/__init__.py、ohos_mvp.py、QUALCODER_OHOS_MVP 标记。
  5. 若改过运行时内容,递增 Index.ets 的 RUNTIME_MARKER。
  6. ohpm install --prefix entry,把本地 libqualcoder_native.so 类型模块挂进依赖。
  7. 用 DevEco 对 org.qualcoder.native 自动签名(或填本机签名材料,勿提交)。
  8. hvigorw.bat --mode module -p module=entry@default -p product=default assembleHap --no-daemon。
  9. 安装 entry-default-signed.hap,首次启动看底部日志是否出现 QualCoder core ready 与 MVP_CORE_SUCCESS。
  10. 按第九章五步动线做真机验收;再按 11.2 边界表抽测 5 项。
  11. 出图留证:空工作台、示例载入、选取模式、编码高亮、多代码/关于。

一条经验:任何"看起来没反应"的问题,先看底部日志栏。这一版把 Python 回包和 traceback 原样显示出来,绝大多数故障在第一次点击后就能定位,不需要连调试器。


十五、十二条可迁移的经验

  1. 先定边界再写代码:明确"垂直切片"而不是"完整移植",能避免把 80% 时间耗在 PyQt6 平台适配上。
  2. 数据模型比界面更值得复用:保住 source/code_name/code_text 的语义,将来换 UI、补 SQLite 都不返工。
  3. 沙箱里"数据"和"代码"要物理分开:stdlib 解压到 el2 当数据读,C 扩展留在 HAP libs 当代码加载。
  4. 静态整链三件套:--whole-archive + --export-dynamic + strip: false,缺一个都可能在真机才炸。
  5. 给运行时加版本 marker:否则"改了代码不生效"会伪装成业务逻辑 bug。
  6. 跨语言用"最后一行纯 JSON"做最小 RPC:调试可见、扩展便宜;长期要升级成结构化调用。
  7. 任何 JSON.parse 结果都不能直接进 ForEach:Array.isArray 是保命符。
  8. ArkUI 属性链只读预计算字段:方法调用、Span.backgroundColor 这类静默失败,比报错更贵。
  9. 文本选区回调要过滤空选区:失焦会吞掉用户刚拖好的区间。
  10. 把降级路径写进返回值:storage: "manifest" 让"缺扩展"变成可观测状态,而不是玄学。
  11. 启动自检要真的建项目、写文件、断言:MVP_CORE_SUCCESS 比"界面能打开"可靠一个数量级。
  12. 零权限是设计,不是限制: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:关键常量与路径速查

常量 / 路径值所在文件
bundleNameorg.qualcoder.nativeAppScope/app.json5
显示名质码工坊(from QualCoder)AppScope/resources/base/element/string.json
RUNTIME_ZIPohos_python_stdlib.zipIndex.ets
RUNTIME_MARKERREADY_QUALCODER_P1_0_0_2Index.ets
BUILD_TAGqualcoder-native-1.0.0-mvpIndex.ets
Python homefilesDir/ohos_pythonIndex.ets
项目目录filesDir/QualCoderProjects/mvp.qdaIndex.ets
清单文件<项目目录>/qualcoder_mvp.jsonohos_mvp.py
目标数据库<项目目录>/data.qdaohos_mvp.py
输出捕获filesDir/py_capture_out.txt / _err.txtqualcoder_napi.cpp
N-API 模块名qualcoder_nativequalcoder_napi.cpp
调色板6 色 #ffe08a #b3e0ff #c8f0c8 #f8c8d8 #e0d0f8 #ffd8b0Index.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_projectProjectSummaryensureProject()
import_textProjectSummaryimportSelected()
summaryProjectSummaryrefreshCodes()
add_code / delete_code{ok, name, codes}addCode() / deleteCode()
code_selection{ok, code, summary}codeSelection()
delete_coding{ok}deleteCoding() / clearCurrentCodings()
list_codesCodeEntry[]refreshCodes()
list_codingsCodingEntry[]refreshCodes()

附录 C:术语表

术语含义
QDAQualitative Data Analysis,定性资料分析
资料(source)导入项目的原始文本,如访谈稿、田野笔记
代码(code)研究者给概念起的标签,如 工作负荷、减负诉求
编码(coding)把某个代码贴到资料的一个字符区间 [pos0, pos1) 上
代码本(codebook)项目内全部代码及其层级、备注、颜色的集合
data.qdaQualCoder 的 SQLite 项目数据库文件
el2鸿蒙应用私有数据区,filesDir 所在层级
rawfileHAP 内不参与资源编译、可整块读出的原始文件目录
HAP / HNPHarmonyOS 应用包 / 原生软件包
marker自定义版本标记文件,用来判断解压目录是否需要刷新

结语

把 QualCoder 搬上鸿蒙 PC,最后落地的是一个 38.78 MB 的 HAP:界面是 ArkTS 写的,业务是 Python 跑的,数据朝着上游 schema 对齐,权限表是空的。它显然还不是完整的 QualCoder——PyQt6 界面、媒体编码、报告与 AI 都还在路线图上——但它已经能在一台鸿蒙 PC 上完成定性研究最核心的那个动作:读一段话,选中一句话,给它一个概念,并且让这个概念在重启后依然存在。

对移植工作而言,比"跑起来"更有价值的,是这套分层留出的余地:补一个 _sqlite3.so 就能换回真 data.qda,补一个 code_cat 面板就能展开代码层级,把 runCode 换成结构化调用就能收紧安全边界。原生 UI 与 Python 内核各守一层,谁都不必为对方让步。

下次再遇到"Python 桌面软件怎么上鸿蒙"这个问题,希望这篇记录里那十二个坑、五张真机图和一份可勾的复现清单,能少让人熬一个晚上。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐