欢迎加入开源鸿蒙 PC 社区:https://harmonypc.csdn.net/

欢迎在 PC 社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper

适配开源地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_QualCoder

一款面向社会科学研究者的定性资料分析工具,怎么在鸿蒙 PC 上从零跑起来并走进应用市场?本文不写"上架流程点哪里",而是把上架背后真正决定成败的技术链路拆开:161 个 Python 模块的 PyQt6 上游如何切成两层、交叉编译的 CPython 3.12 怎样静态链进 HAP、HarmonyOS 链接器命名空间为什么禁止 dlopen 沙箱里的 .so、ArkTS 与 Python 之间怎样约定一份不会崩的 JSON 契约,以及编码高亮在 ArkUI 里踩过的三个静默坑。文中代码全部摘自已开源的真实工程,截图来自应用市场详情页、宣传图与鸿蒙 PC 真机实拍。

鸿蒙 PC 应用市场上的质码工坊

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


真机桌面首启

一、上架成果:应用市场里的「质码工坊」

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

打开应用是这样一个三栏工作台:

打开即用,零配置启动

图 2:应用介绍图「打开即用,零配置启动」。左侧项目面板负责载入资料与创建项目,中间是编码工作区,右侧是代码本与编码记录。界面完全由 ArkUI 声明式组件构成,没有 WebView、没有 Qt、没有嵌入网页。

上架版本已经跑通的核心能力:

能力说明
首启自动部署内嵌 CPython 运行时rawfile zip 解压到应用沙箱,marker 幂等,二次启动秒进
内核自检启动时执行 runTest(),日志出现 MVP_CORE_SUCCESS
项目与资料沙箱内创建 QualCoder 兼容项目,DocumentViewPicker 零权限载入 .txt/.md/.csv/.html/.htm/.tex/.rtf
代码本 CRUD新建 / 删除代码,删除时级联清除其全部编码,实时计数
区间编码拖选一段文字 → 选择或输入代码 → 落到 (code, source, pos0, pos1) 四元组
高亮回读已编码区间按代码着色的彩色高亮,点击查看所属代码
撤回与持久化单条撤回、清空当前资料编码,杀进程重启后数据完整恢复
零权限requestPermissions: [],不申请任何敏感 ACL,无网络访问

这篇文章的重点是这些能力是怎么实现的。上架只是结果,下面从路线选型开始,逐层把代码摊开。


二、路线选型:T0 / T1 / T2,为什么是「T1 内核 + T0 外壳」

源自开源 QualCoder 的鸿蒙原生适配

图 3:应用介绍图「源自开源 QualCoder 的鸿蒙原生适配」。一句话概括技术身份:ArkTS 原生界面 + 内嵌 CPython 3.12 引擎。

2.1 三级框架

鸿蒙 PC 生态通常按迁移深度把应用分成三级:

等级名称迁移方式典型特征
T2兼容运行级不改源码,经兼容容器 / 翻译层运行原程序依赖宿主兼容环境,性能损耗大,系统窗口 / 输入法 / 文件授权难融入
T1引擎迁移级用嵌入式运行时(Python / Electron / Qt / Web / JSVM)承载原核心,仅适配壳层携带数十至数百 MB 运行时,存在跨语言边界
T0原生适配级业务逻辑以 ArkTS / ArkUI 原生移植单进程、零桥接、原生窗口与输入、权限最小化、包体小

三条路线没有绝对对错。真正要做的是按软件形态选。

2.2 上游 QualCoder 的形态

_upstream/QualCoder/src/qualcoder/ 下有 161 个 Python 模块,依赖 PyQt6 ≥ 6.5、pymupdf、Pillow、python-vlc、EbookLib、pdfminer.six、pandas、matplotlib、plotly、networkx、langchain、faiss-cpu、transformers……这些包在 aarch64-ohos 上既没有轮子,PyQt6 更没有平台插件。

但拆开看,形态是「厚依赖、重数据模型、薄算法、重 GUI」:

  • 最值钱的资产不是算法,是数据契约:.qda 的 SQLite 表结构、"代码贴到资料 [pos0, pos1) 区间"的语义、资料登记与去重规则;
  • 界面可以完全重写:桌面版 GUI 是 161 个模块里最大的一块,鸿蒙版只要三栏;
  • 调用特征是低频 IO:一次"贴代码"是几毫秒的字典与文件操作,跨语言开销可忽略。

于是分层就定了:

在这里插入图片描述

T1 内核 + T0 外壳:领域层复用上游数据模型,界面层 100% ArkUI,全程不落 T2 容器。

2.3 一个有意思的对照

同一工作区的光学设计项目 ohos_Optiland 曾走过几乎相同的 T1 路线(静态链 CPython + NAPI 桥 + 首启解压运行时),最终却放弃 T1 转向纯 T0,理由是首启解压约 1 分钟、单次操作 5–10 秒,而"改一个曲率半径立刻看光路"是产品生命线。

本项目保留了 T1 且跑通,差别只在交互频率与单次计算量:

维度光学设计(放弃 T1)质码工坊(保留 T1)
核心操作每次编辑触发全量三视场光线追迹一次点击写一条编码记录
调用频率每帧 / 每敲一个字符每几秒一次人工点击
人类感知阈值几十毫秒即卡顿几百毫秒无感
结论跨语言开销打断编辑循环 → 必须 T0开销可忽略 → T1 换数据模型保真,划算

所以领域层只保留 9 个函数、341 行:T1 的价值在于复用契约,不在于搬运代码量。搬得越少,运行时越轻,越不容易掉进 T1 的性能陷阱。


代码说明:下文代码块均摘自已开源工程的真实源码。配置类片段为节选(只保留与讨论相关的字段),
其余为便于阅读做了换行与排版合并,未改动任何标识符、字段名或逻辑;完整源码以仓库为准。

三、第一块硬骨头:把 CPython 3.12 静态链进 HAP

3.1 CMake:两行链接参数决定生死

entry/src/main/cpp/CMakeLists.txt 全文只有 31 行,但每一行都不能省:

cmake_minimum_required(VERSION 3.4.1)
project(QualCoderNative C CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# Real cross-compiled CPython 3.12 (static) for OHOS aarch64
set(PYTHON_INC "${CMAKE_CURRENT_SOURCE_DIR}/python_inc")
set(PYTHON_LIB "${CMAKE_CURRENT_SOURCE_DIR}/prebuilt/libpython3.12.a")

add_library(qualcoder_native SHARED qualcoder_napi.cpp)

# Extension modules (lib-dynload .so files) are dlopened at runtime and
# resolve libpython symbols from this host .so. --export-dynamic pushes our
# dynsym entries into the global namespace so RTLD_LOCAL dlopen of those
# extensions can relocate against them.
target_link_options(qualcoder_native PRIVATE -Wl,--export-dynamic)

target_include_directories(qualcoder_native PRIVATE "${PYTHON_INC}")

# whole-archive: pull full interpreter from static archive into the NAPI .so
target_link_libraries(qualcoder_native PUBLIC
    -Wl,--whole-archive
    "${PYTHON_LIB}"
    -Wl,--no-whole-archive
    libace_napi.z.so
    libhilog_ndk.z.so
    libc++.a
    libm.a
    libdl.a
)

两个关键点:

  1. -Wl,--whole-archive … -Wl,--no-whole-archive:静态库默认只拉入被直接引用的目标文件。不加 whole-archive,libpython3.12.a(33.32 MB)里大量内建模块符号会在解释器启动后缺失。
  2. -Wl,--export-dynamic:把宿主 .so 的动态符号推进全局符号域,让后续被 dlopen 的 C 扩展能重定位到宿主里的 PyModuleDef_Init 等 libpython 符号。

对应 entry/build-profile.json5 打开 native 编译:

{
  "apiType": "stageMode",
  "buildOption": {
    "externalNativeOptions": {
      "path": "./src/main/cpp/CMakeLists.txt",
      "arguments": "",
      "abiFilters": ["arm64-v8a"],
      "cppFlags": "-std=c++17"
    },
    "nativeLib": { "debugSymbol": { "strip": false } }
  },
  "targets": [{ "name": "default", "runtimeOS": "HarmonyOS" }]
}

strip: false 是刻意的:真机抓 native 栈时保留符号,38.78 MB 的包体里这部分很值。

3.2 el2 目录禁止 dlopen:扩展必须随 HAP libs 分发

HarmonyOS 的链接器命名空间禁止对应用沙箱 filesDir(el2)里解压出来的 .so 执行 dlopen。所以 lib-dynload/*.so 不能留在 zip 里,必须随 HAP 的 entry/libs/arm64-v8a/(el1 bundle 路径)分发。

tools/extract_dynload.py 负责抽取,并留了一道很硬的安全网:

DEFAULT_SOURCE = ROOT / "ohos_hap/entry/src/main/resources/rawfile/ohos_python_stdlib.zip"
DEFAULT_TARGET = ROOT / "ohos_hap/entry/libs/arm64-v8a"
DYNLOAD_PREFIX = "ohos_python/lib/python3.12/lib-dynload/"

def extract(source: Path, target: Path) -> list[str]:
    if not source.is_file():
        raise SystemExit(f"runtime zip not found: {source}")
    target.mkdir(parents=True, exist_ok=True)
    # wipe stale non-so artifacts (safety net)
    for stale in target.iterdir():
        if not stale.name.endswith(".so"):
            raise SystemExit(
                f"unexpected non-so file in libs dir: {stale} — remove it manually"
            )
    written: list[str] = []
    with zipfile.ZipFile(source) as z:
        for info in z.infolist():
            if not (info.filename.startswith(DYNLOAD_PREFIX) and info.filename.endswith(".so")):
                continue
            name = info.filename.rsplit("/", 1)[-1]
            dest = target / name
            dest.write_bytes(z.read(info.filename))
            dest.chmod(0o755)
            written.append(name)
    if not written:
        raise SystemExit("no lib-dynload .so found in the runtime zip")
    return written

那个 for stale in target.iterdir() 循环来自一次惨痛教训:build-profile.json5 开了 collectAllLibs: true,该目录里任何非 .so 文件都会被打包进 HAP。早期有扫描产物落在里面,包体凭空涨了 100 MB。现在脚本遇到杂文件直接拒绝执行。

3.3 DT_NEEDED 补丁:让扩展找到 libpython

即便宿主提升到 RTLD_GLOBAL,NAPI 框架以 RTLD_LOCAL 加载的扩展仍可能报 Error relocating ... PyModuleDef_Init: symbol not found。tools/patch_dynload_needed.py 用 lief 把依赖显式写进 ELF:

HOST_SONAME = "libqualcoder_native.so"

def patch_dir(target: Path) -> int:
    patched = 0
    for so_path in sorted(target.glob("*.so")):
        if so_path.name == HOST_SONAME:
            continue
        bin_ = lief.parse(str(so_path))
        if bin_ is None:
            raise SystemExit(f"cannot parse ELF: {so_path}")
        needed = [e.name for e in bin_.dynamic_entries
                  if e.tag == lief.ELF.DynamicEntry.TAG.NEEDED]
        if HOST_SONAME in needed:
            continue                      # 幂等:已打过就跳过
        if not bin_.add_library(HOST_SONAME):
            raise SystemExit(f"failed to add DT_NEEDED to {so_path}")
        bin_.write(str(so_path))
        patched += 1
    return patched

54 个扩展全部打上 DT_NEEDED libqualcoder_native.so 后,import math / import zlib / import _json 才真正可用。

3.4 运行时 zip 的重建

tools/build_runtime.py 从 Thonny 版 CPython 3.12 基线 zip 重建发行包,剔除 vendored 编辑器与 lib-dynload,注入业务包:

PACKAGE_PREFIX = "ohos_python/lib/python3.12/site-packages/qualcoder/"
DROP_PREFIXES = (
    "ohos_python/lib/python3.12/site-packages/thonny/",
    PACKAGE_PREFIX,
)
DYNLOAD_PREFIX = "ohos_python/lib/python3.12/lib-dynload/"
PACKAGE_FILES = ("__init__.py", "ohos_mvp.py")

def build(source: Path, package: Path, target: Path) -> dict[str, int]:
    dropped = 0
    copied = 0
    target.parent.mkdir(parents=True, exist_ok=True)
    with zipfile.ZipFile(source) as src, zipfile.ZipFile(
        target, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6
    ) as dst:
        for info in src.infolist():
            if any(info.filename.startswith(prefix) for prefix in DROP_PREFIXES):
                dropped += 1
                continue
            if info.filename.startswith(DYNLOAD_PREFIX) and info.filename.endswith(".so"):
                dropped += 1
                continue
            data = b"" if info.is_dir() else src.read(info.filename)
            dst.writestr(info, data)
            copied += 1
        dst.writestr(PACKAGE_PREFIX, b"")
        for name in PACKAGE_FILES:
            dst.writestr(PACKAGE_PREFIX + name, (package / name).read_bytes())
        dst.writestr(PACKAGE_PREFIX + "QUALCODER_OHOS_MVP",
                     b"qualcoder-ohos-mvp 1.0.0\n")
    return {"kept": copied, "dropped": dropped, "size": target.stat().st_size}

产物 ohos_python_stdlib.zip 4.39 MB,包含 CPython 3.12 标准库与 site-packages/qualcoder/{__init__,ohos_mvp}.py。注意 ohos_hap 内没有 .py 副本:改业务必须改上游那份再重打包。


四、NAPI 桥:进程内解释器与文件捕获 stdout

4.1 只暴露 4 个方法

cpp/types/libqualcoder_native/index.d.ts:

interface QualCoderNativeApi {
  getBuildInfo(): string;
  setPythonHome(home: string): string;
  runTest(): string;
  runCode(code: string): string;
}

declare const qualcoderNative: QualCoderNativeApi;
export default qualcoderNative;

C++ 侧注册(qualcoder_napi.cpp):

static napi_value Export(napi_env env, napi_value exports) {
    napi_property_descriptor desc[] = {
        {"getBuildInfo", nullptr, GetBuildInfo, nullptr, nullptr, nullptr, napi_default, nullptr},
        {"setPythonHome", nullptr, SetPythonHome, nullptr, nullptr, nullptr, napi_default, nullptr},
        {"runTest", nullptr, RunTest, nullptr, nullptr, nullptr, napi_default, nullptr},
        {"runCode", nullptr, RunCode, nullptr, nullptr, nullptr, napi_default, nullptr},
    };
    napi_define_properties(env, exports, sizeof(desc) / sizeof(desc[0]), desc);
    return exports;
}

EXTERN_C_START
static napi_module demoModule = {
    .nm_version = 1,
    .nm_flags = 0,
    .nm_filename = nullptr,
    .nm_register_func = Export,
    .nm_modname = "qualcoder_native",
    .nm_priv = ((void *)0),
    .reserved = {0}
};
EXTERN_C_END

extern "C" __attribute__((constructor)) void RegisterModule(void) {
    napi_module_register(&demoModule);
}

全部业务都走 runCode(字符串) 这一个入口——实现最简、不必为每个函数写 NAPI 包装。代价是后面要讲的注入面,属于明确登记的技术债。

4.2 单例状态与目录诊断

static std::mutex g_pyMu;
static bool g_pyInited = false;
static std::string g_pyHome;

static std::string ProbeHome(const std::string &home) {
    const std::string lib = home + "/lib/python3.12";
    const std::string osPy = lib + "/os.py";
    std::string msg = "home_access=" + std::string(PathAccessOk(home) ? "y" : "n");
    msg += " home_dir=" + std::string(DirExists(home) ? "y" : "n");
    msg += " lib_dir=" + std::string(DirExists(lib) ? "y" : "n");
    msg += " os.py=" + std::string(FileExists(osPy) ? "y" : "n");
    if (DirExists(home)) {
        msg += " home_ls=[" + DirListing(home) + "]";
    }
    return msg;
}

ProbeHome() 把「解压到底成没成功」变成一行可直接读的字符串回传给 ArkTS。真机排障时,home_dir=y lib_dir=y os.py=y 与一串 home_ls=[...] 比任何日志都直观。

4.3 EnsurePyInitLocked():初始化解释器

这是整个桥的核心,56 行:

static std::string EnsurePyInitLocked() {
    if (g_pyInited) {
        return "already";
    }
    if (g_pyHome.empty()) {
        return "ERR: setPythonHome first";
    }
    const std::string lib = g_pyHome + "/lib/python3.12";
    const std::string osPy = lib + "/os.py";
    if (!DirExists(lib) || !FileExists(osPy)) {
        return std::string("ERR: missing stdlib under ") + g_pyHome + " | " + ProbeHome(g_pyHome);
    }
    const std::string site = lib + "/site-packages";
    // C extensions live next to this .so in the HAP libs dir (el1 bundle
    // path). The linker forbids dlopen on el2 files, so lib-dynload must
    // resolve here. Derive the libs dir from our own module path.
    std::string dynload = g_pyHome + "/lib/python3.12/lib-dynload";
    Dl_info info {};
    if (dladdr(reinterpret_cast<void *>(&EnsurePyInitLocked), &info) && info.dli_fname) {
        std::string self(info.dli_fname);
        // Promote ourselves into the global scope (RTLD_NOLOAD on an
        // already-loaded object just re-flags it). Without this, extension
        // modules dlopened by the import machinery cannot relocate
        // libpython symbols against this host .so.
        dlopen(self.c_str(), RTLD_LAZY | RTLD_GLOBAL | RTLD_NOLOAD);
        const size_t slash = self.rfind('/');
        if (slash != std::string::npos) {
            const std::string dir = self.substr(0, slash);
            if (FileExists(dir + "/math.cpython-312-aarch64-linux-ohos.so")) {
                dynload = dir;
            }
        }
    }
    setenv("PYTHONHOME", g_pyHome.c_str(), 1);
    setenv("PYTHONPATH", (lib + ":" + site + ":" + dynload).c_str(), 1);
    setenv("PYTHONUNBUFFERED", "1", 1);
    setenv("PYTHONDONTWRITEBYTECODE", "1", 1);
    setenv("PYTHONNOUSERSITE", "1", 1);
    wchar_t *wHome = Py_DecodeLocale(g_pyHome.c_str(), nullptr);
    if (wHome != nullptr) {
        Py_SetPythonHome(wHome);
    }
    Py_InitializeEx(0);
    if (!Py_IsInitialized()) {
        return "ERR: Py_InitializeEx failed";
    }
    const std::string inject =
        "import sys\n"
        "p = " + PyQuote(site) + "\n"
        "if p not in sys.path: sys.path.insert(0, p)\n"
        "d = " + PyQuote(dynload) + "\n"
        "if d not in sys.path: sys.path.append(d)\n";
    PyRun_SimpleString(inject.c_str());
    g_pyInited = true;
    return "ok";
}

四个动作缺一不可:

  • dladdr(&EnsurePyInitLocked, &info) 拿自身 .so 路径,推出 HAP 的 libs 目录;用 math.cpython-312-aarch64-linux-ohos.so 是否存在当探针;
  • dlopen(self, RTLD_LAZY | RTLD_GLOBAL | RTLD_NOLOAD) 把已加载的宿主提升到全局符号域;
  • PYTHONHOME / PYTHONPATH / PYTHONDONTWRITEBYTECODE 等五个环境变量一次设齐;
  • Py_SetPythonHome → Py_InitializeEx(0) → 再用 PyRun_SimpleString 把 site-packages 插到 sys.path[0]、把 dynload 追加到末尾。只设环境变量不注入 sys.path,某些初始化顺序下 import qualcoder 仍会失败。

4.4 RunPythonCode():文件捕获而不是管道

static std::string RunPythonCode(const std::string &code, const char *phaseTag) {
    std::lock_guard<std::mutex> lock(g_pyMu);
    const std::string init = EnsurePyInitLocked();
    if (init.rfind("ERR", 0) == 0) {
        return init;
    }
    const size_t slash = g_pyHome.rfind('/');
    const std::string filesDir = slash == std::string::npos ? g_pyHome : g_pyHome.substr(0, slash);
    const std::string outFile = filesDir + "/py_capture_out.txt";
    const std::string errFile = filesDir + "/py_capture_err.txt";
    std::string indented;
    size_t i = 0;
    while (i <= code.size()) {
        size_t j = code.find('\n', i);
        if (j == std::string::npos) {
            j = code.size();
        }
        indented += "    ";
        indented.append(code, i, j - i);
        indented.push_back('\n');
        if (j >= code.size()) {
            break;
        }
        i = j + 1;
    }
    const std::string wrapped =
        "import sys\n"
        "class _Capture:\n"
        "    def __init__(self, path): self.f = open(path, 'w', encoding='utf-8')\n"
        "    def write(self, value): self.f.write(value)\n"
        "    def flush(self): self.f.flush()\n"
        "    def close(self): self.f.close()\n"
        "_out = _Capture(" + PyQuote(outFile) + ")\n"
        "_err = _Capture(" + PyQuote(errFile) + ")\n"
        "sys.stdout = _out\n"
        "sys.stderr = _err\n"
        "try:\n" + indented +
        "except Exception:\n"
        "    import traceback\n"
        "    traceback.print_exc()\n"
        "finally:\n"
        "    _out.flush(); _err.flush(); _out.close(); _err.close()\n";
    const int rc = PyRun_SimpleString(wrapped.c_str());
    std::string report = std::string("[") + phaseTag + "] real CPython 3.12\n";
    report += "[" + std::string(phaseTag) + "] PyRun_SimpleString rc=" + std::to_string(rc) + "\n";
    const std::string out = ReadFile(outFile);
    const std::string err = ReadFile(errFile);
    if (!out.empty()) {
        report += "--- stdout ---\n" + out;
        if (out.back() != '\n') report += "\n";
    }
    if (!err.empty()) {
        report += "--- stderr ---\n" + err;
        if (err.back() != '\n') report += "\n";
    }
    return report;
}

要点:

  • 传入代码逐行缩进 4 空格后嵌入 try/except/finally 模板,业务代码不需要自己包异常;
  • _Capture 用文件而非管道:OHOS 社区移植的定制 libpython 在 fork 子进程里存在 Py_BytesMain 空跑、io 层写盘静默丢失等问题,文件方案简单、可读、可留证;
  • finally 里必须 flush + close,否则 C++ 读回的是空内容或截断内容;
  • 报告格式固定为 [MVP] … / --- stdout --- / --- stderr ---,ArkTS 侧按分隔符解析。

4.5 内置自检

static napi_value RunTest(napi_env env, napi_callback_info info) {
    (void)info;
    const char *code =
        "import sys, tempfile\n"
        "from pathlib import Path\n"
        "from qualcoder.ohos_mvp import create_project, import_text, summary\n"
        "root = Path(tempfile.gettempdir()) / 'qualcoder_mvp_test.qda'\n"
        "if root.exists():\n"
        "    import shutil\n"
        "    shutil.rmtree(root)\n"
        "create_project(str(root), 'MVP Test')\n"
        "import_text(str(root), 'interview.txt', 'HarmonyOS QualCoder MVP\\n')\n"
        "result = summary(str(root))\n"
        "print('QualCoder core ready')\n"
        "print('python', sys.version.split()[0])\n"
        "print('project', result['name'])\n"
        "print('sources', result['sources'])\n"
        "print('storage', (root / 'data.qda').is_file() or (root / 'qualcoder_mvp.json').is_file())\n"
        "assert result['sources'] == 1\n"
        "assert (root / 'data.qda').is_file() or (root / 'qualcoder_mvp.json').is_file()\n"
        "print('MVP_CORE_SUCCESS')\n";
    std::string report = RunPythonCode(code, "MVP");
    const bool ok = report.find("MVP_CORE_SUCCESS") != std::string::npos &&
                    report.find("PyRun_SimpleString rc=0") != std::string::npos;
    report += ok
        ? "[MVP] SUCCESS: QualCoder SQLite project and text import core passed.\n"
        : "[MVP] FAIL: QualCoder core self-check did not pass.\n";
    return MakeStr(env, report);
}

自检建临时项目、导入文本、断言计数,只有标记与 rc=0 同时满足才判成功。图 4 里底部日志的 QualCoder core ready 就是它打出来的。

4.6 为什么不 fork、不 exec、不用管道

工程里没有 fork / exec / popen / system() / socket / pipe IPC。HarmonyOS 沙箱禁止 exec 外部二进制,filesDir 带 noexec 语义;定制 libpython 在 fork 子进程里还会遇到 Py_BytesMain 返回 0 却不执行脚本、以及 open() 写盘静默丢失(只有 os.write(1, …) 直通系统调用可靠)。

所以选了最朴素的一条:解释器就是应用进程里的一段代码,Py_InitializeEx 一次常驻,所有调用 PyRun_SimpleString 同步完成,由 g_pyMu 保证串行。代价是 Python 调用串行、且在 UI 线程执行;收益是没有进程生命周期、没有 IPC 序列化、没有半路丢输出。


五、ArkTS 工作台:首启部署、零权限读写与编码闭环

5.1 prepareRuntime():marker 幂等

Index.ets 在 aboutToAppear() 里取 filesDir,拼出 pythonHome = filesDir/ohos_python,然后:

const RUNTIME_ZIP = 'ohos_python_stdlib.zip';
const RUNTIME_MARKER = 'READY_QUALCODER_P1_0_0_2';

private async prepareRuntime(): Promise<void> {
  if (this.busy || !this.context) {
    return;
  }
  this.busy = true;
  this.status = '准备 Python 运行时…';
  try {
    const marker = `${this.pythonHome}/.${RUNTIME_MARKER}`;
    const osPy = `${this.pythonHome}/lib/python3.12/os.py`;
    if (!openable(osPy) || !exists(marker) || readText(marker).indexOf(RUNTIME_MARKER) < 0) {
      mkdirp(this.pythonHome);
      const zipPath = `${this.filesDir}/${RUNTIME_ZIP}`;
      const content = await this.context.resourceManager.getRawFileContent(RUNTIME_ZIP);
      const zip = fileIo.openSync(zipPath,
        fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.TRUNC);
      try {
        fileIo.writeSync(zip.fd, content.buffer as ArrayBuffer);
      } finally {
        fileIo.closeSync(zip);
      }
      await zlib.decompressFile(zipPath, this.filesDir);
      if (!openable(osPy)) {
        throw new Error(`运行时缺少 ${osPy}`);
      }
      writeText(marker, `${RUNTIME_MARKER}\n`);
      this.log('运行时已解压');
    } else {
      this.log('复用已部署运行时');
    }
    const homeResult = qualcoderNative.setPythonHome(this.pythonHome);
    if (homeResult.indexOf('ERR') >= 0 || homeResult.indexOf('os.py=n') >= 0) {
      throw new Error(homeResult);
    }
    this.log(`home ${homeResult}`);
    this.runtimeReady = true;
    this.status = '运行时就绪';
    this.log('CPython 3.12 已连接');
    this.ensureProject();
    this.refreshCodes();
    this.runSelfCheck();
  } catch (e) {
    this.status = `运行时失败:${(e as Error).message ?? String(e)}`;
    this.log(this.status);
  } finally {
    this.busy = false;
  }
}

判据是「os.py 可读 且 marker 内容匹配」双条件,避免解压中断留下半截运行时。衍生坑:改了 ohos_mvp.py 并重跑 build_runtime.py 后,如果忘了递增 RUNTIME_MARKER,应用会认为运行时已是最新、继续用沙箱里的旧 Python 文件,表现为「代码改了完全没生效」。这个坑足以浪费半天。

5.2 三栏布局

build() {
  Stack({ alignContent: Alignment.Center }) {
    Column() {
      Row() {
        Text('质码工坊').fontSize(20).fontWeight(FontWeight.Bold).fontColor(ACCENT)
        Text(`  ${this.status}`).fontSize(13)
          .fontColor(this.status.includes('失败') ? BAD : MUTED)
          .layoutWeight(1).margin({ left: 12 })
        Text('关于').fontSize(14).fontColor(ACCENT).padding(8)
          .onClick(() => this.showAbout = true)
      }
      .height(48).width('100%').padding({ left: 14, right: 10 })
      .backgroundColor(FRAME_BG)
      .border({ width: { bottom: 1 }, color: FRAME_BORDER })
      Row() {
        this.ProjectPanel()     // .width('24%')
        this.CodingPanel()      // .layoutWeight(1)
        this.CodePanel()        // .width('30%')
      }.layoutWeight(1).width('100%')
      Row() {
        Text('日志').fontSize(13).fontColor(MUTED).margin({ left: 10, right: 10 })
        Text(this.logLines.slice(-1)[0] ?? '').fontSize(12).fontColor(MUTED)
          .maxLines(1).layoutWeight(1)
      }.height(28).width('100%').backgroundColor('#eceae5')
    }.width('100%').height('100%')
    if (this.showAbout) {
      this.AboutDialog()
    }
  }
  .width('100%').height('100%').backgroundColor('#efeee9')
}

48px 顶栏 + 三栏 + 28px 日志栏。日志栏只显示最后一条,但 log() 保留最近 120 条——真机排障时 Python 回包与 traceback 都在这里。

5.3 零权限读取外部文件

module.json5 里 requestPermissions: [],外部文本靠系统选择器用户一次性授权:

private async pickText(): Promise<void> {
  if (!this.context || !this.runtimeReady || this.busy) {
    return;
  }
  this.busy = true;
  try {
    const pickerView = new picker.DocumentViewPicker(this.context);
    const options = new picker.DocumentSelectOptions();
    options.fileSuffixFilters = ['.txt', '.md', '.csv', '.html', '.htm', '.tex', '.rtf'];
    const uris = await pickerView.select(options);
    if (!uris || uris.length === 0) {
      return;
    }
    const uri = uris[0];
    const source = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY);
    try {
      const stat = fileIo.statSync(source.fd);
      const buffer = new ArrayBuffer(Number(stat.size));
      const count = fileIo.readSync(source.fd, buffer);
      this.selectedText = decodeText(new Uint8Array(buffer, 0, count));
      this.selectedName = nameFromUri(uri);
      this.status = `已载入:${this.selectedName}`;
      this.log(`资料载入:${this.selectedName}(${count} bytes)`);
    } finally {
      fileIo.closeSync(source);
    }
  } catch (e) {
    this.status = `载入失败:${(e as Error).message ?? String(e)}`;
    this.log(this.status);
  } finally {
    this.busy = false;
  }
}

function decodeText(bytes: Uint8Array): string {
  const text = util.TextDecoder.create('utf-8').decodeToString(bytes);
  return text.indexOf('\uFFFD') >= 0
    ? util.TextDecoder.create('gb18030').decodeToString(bytes)
    : text;
}

两个 API 细节:fileIo.openSync() 返回的是 File 对象,读写必须传 .fd;util.TextDecoder.decodeToString 需要 Uint8Array。中文编码用 UTF-8 优先、检测到 \uFFFD 回退 GB18030 的双判据。

注意载入 ≠ 入库:pickText() 只把文本放进内存,必须再点「导入当前资料」才调 import_text 写进项目。图 5 里 sources: 0 正是"载入示例但还没导入"的状态,这个设计让浏览与入库可分离。

5.4 一次「贴代码」的完整调用链

private codeSelection(): void {
  const selText = this.selectedText.substring(this.selStart, this.selEnd);
  if (this.selStart < 0 || this.selEnd <= this.selStart || selText.length === 0) {
    this.status = '请先在资料中选中一段文字';
    return;
  }
  const code = this.pickedCode || this.newCode.trim();
  if (code.length === 0) {
    this.status = '请先选择或输入代码';
    return;
  }
  const path = this.projectPath();
  mkdirp(path);
  const result = this.runJson(
    `import json\nfrom qualcoder.ohos_mvp import code_selection\n` +
    `print(json.dumps(code_selection(${this.pyQuote(path)}, ${this.pyQuote(code)}, ` +
    `${this.pyQuote(this.selectedName || 'document.txt')}, ${this.pyQuote(selText)}, ` +
    `${this.selStart}, ${this.selEnd}), ensure_ascii=False))`
  );
  try {
    const parsed = JSON.parse(result) as CodingResult;
    if (parsed.ok === true) {
      if (parsed.summary) {
        this.project = parsed.summary;
      }
      this.refreshCodes();
      this.viewMode = true;
      this.selStart = -1;
      this.selEnd = -1;
      this.status = `已编码「${code}」:${selText.length > 20 ? `${selText.substring(0, 20)}…` : selText}`;
    } else {
      this.status = '编码失败';
    }
  } catch (e) {
    this.status = '编码失败';
    this.log(`code_selection 返回:${result}`);
    this.log(`${(e as Error).message ?? String(e)}`);
  }
}

private runJson(code: string): string {
  const output = parseReport(qualcoderNative.runCode(code));
  this.log(output);
  const last = output.lastIndexOf('\n');
  return last >= 0 ? output.substring(last + 1).trim() : output.trim();
}

function parseReport(report: string): string {
  const marker = '--- stdout ---';
  const error = '--- stderr ---';
  const begin = report.indexOf(marker);
  if (begin < 0) {
    return report;
  }
  const from = begin + marker.length;
  const end = report.indexOf(error, from);
  return report.substring(from, end >= 0 ? end : report.length).trim();
}

private pyQuote(value: string): string {
  return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n')}'`;
}

跨语言契约就三段:ArkTS 拼 Python 源码 → C++ 返回带分隔符的报告 → ArkTS 取 stdout 最后一行交给 JSON.parse。约定业务函数一律返回 dict 或 list,且领域层保持安静、只用返回值说话——任何多余的 print 都会污染最后一行。

5.5 高亮渲染:buildChunks + Span

interface TextChunk {
  text: string;
  code: string;
  color: string;
  bold: boolean;
}

const CODE_COLORS: string[] =
  ['#ffe08a', '#b3e0ff', '#c8f0c8', '#f8c8d8', '#e0d0f8', '#ffd8b0'];

function colorForCode(code: string): string {
  let hash = 0;
  for (let i = 0; i < code.length; i++) {
    hash = (hash * 31 + code.charCodeAt(i)) % 997;
  }
  return CODE_COLORS[hash % CODE_COLORS.length];
}

function buildChunks(text: string, codings: CodingEntry[]): TextChunk[] {
  const sorted = codings
    .filter((item) => item.pos0 >= 0 && item.pos1 > item.pos0 && item.pos1 <= text.length)
    .sort((a, b) => a.pos0 - b.pos0 || b.pos1 - a.pos1);
  const chunks: TextChunk[] = [];
  let cursor = 0;
  for (const span of sorted) {
    if (span.pos0 < cursor) {
      continue; // overlaps a previous span, skip
    }
    if (span.pos0 > cursor) {
      chunks.push({ text: text.substring(cursor, span.pos0),
                    code: '', color: '#00000000', bold: false });
    }
    chunks.push({ text: text.substring(span.pos0, span.pos1), code: span.code,
                  color: colorForCode(span.code), bold: true });
    cursor = span.pos1;
  }
  if (cursor < text.length) {
    chunks.push({ text: text.substring(cursor), code: '',
                  color: '#00000000', bold: false });
  }
  return chunks;
}

渲染侧把 Text 切成 Span 流:

Text() {
  ForEach(buildChunks(this.selectedText, this.currentCodings),
    (chunk: TextChunk, index?: number) => {
      Span(chunk.text)
        .fontWeight(chunk.bold ? FontWeight.Bold : FontWeight.Normal)
        .textBackgroundStyle({ color: chunk.color, radius: 4 })
        .onClick(() => {
          if (chunk.code.length > 0) {
            this.status = `此段已编码为「${chunk.code}」`;
          }
        })
    },
    (chunk: TextChunk, index?: number) => `${index}-${chunk.code}-${chunk.text.length}`)
}
.fontSize(14).fontColor(TEXT).lineHeight(24).textAlign(TextAlign.Start).width('100%')

5.6 三个真正会杀进程的 ArkUI 坑

坑一:dict 直接喂给 ForEach。add_code / delete_code 返回 {ok, name, codes},早期整个赋给 @State codeEntries: CodeEntry[],ArkUI 抛 data source dose not have forEach function(103806)并终止应用。修法是取字段 + 类型校验:

const parsed = JSON.parse(result) as CodeOpResult;
const codes = parsed?.codes;
if (Array.isArray(codes)) {
  this.codeEntries = codes;
  this.newCode = '';
  this.status = `已添加代码:${value}`;
} else {
  this.status = '添加代码失败';
  this.log(`add_code 返回:${result}`);
}

所有 JSON.parse 结果赋给数组状态的地方都要加这层防护。

坑二:Span 的 backgroundColor 与属性链里的函数调用会被静默忽略。Span 不是普通组件,背景必须用 .textBackgroundStyle({color, radius});属性链里调 colorForCode(chunk.code) 这类方法也不生效——所以颜色和 bold 必须像上面那样在 buildChunks() 里预计算进 TextChunk 字段,属性链只读字段。

坑三:TextArea 失焦清空选区。点「编码选中文字」时失焦会触发一次 onTextSelectionChange(start == end),无脑写入就把刚拖好的选区覆盖成空:

TextArea({ text: this.selectedText })
  .onTextSelectionChange((start: number, end: number) => {
    if (end > start) {          // 只在非空选区时更新
      this.selStart = start;
      this.selEnd = end;
    }
  })

同类经验:.width()/.height() 读普通 @State 变量运行时可能不更新,三元表达式返回字面量百分比才可靠——本工程三栏固定用 '24%' / '30%' 字面量,正好避开。

真机桌面首启

图 4:鸿蒙 PC 真机桌面实拍。窗口标题「质码工坊(from QualCoder)」,三栏工作台完整渲染,左栏显示 MVP 项目 与沙箱路径 /data/storage/el2/base/haps/entry/files/QualCoderProjects/mvp.qda,底部日志栏打出 QualCoder core ready——runTest() 自检通过。


六、领域层:QualCoder 数据模型与双通道降级

6.1 上游兼容的 5 张表

ohos_mvp.py 保留的是上游真正的资产——数据契约:

try:
    import sqlite3
except Exception:
    sqlite3 = None

SCHEMA = (
    "CREATE TABLE IF NOT EXISTS project (databaseversion TEXT, date TEXT, memo TEXT, about TEXT, "
    "bookmarkfile INTEGER, bookmarkpos INTEGER, codername TEXT, recently_used_codes TEXT, "
    "avbookmarkfile INTEGER, avbookmarkmsec INTEGER, avbookmarktextpos INTEGER)",
    "CREATE TABLE IF NOT EXISTS source (id INTEGER PRIMARY KEY, name TEXT UNIQUE, fulltext TEXT, "
    "mediapath TEXT, memo TEXT, owner TEXT, date TEXT, av_text_id INTEGER, risid INTEGER)",
    "CREATE TABLE IF NOT EXISTS code_cat (catid INTEGER PRIMARY KEY, name TEXT UNIQUE, owner TEXT, "
    "date TEXT, memo TEXT, supercatid INTEGER)",
    "CREATE TABLE IF NOT EXISTS code_name (cid INTEGER PRIMARY KEY, name TEXT UNIQUE, memo TEXT, "
    "catid INTEGER, owner TEXT, date TEXT, color TEXT, supercid INTEGER)",
    "CREATE TABLE IF NOT EXISTS code_text (ctid INTEGER PRIMARY KEY, cid INTEGER, fid INTEGER, "
    "seltext TEXT, pos0 INTEGER, pos1 INTEGER, owner TEXT, date TEXT, memo TEXT, avid INTEGER, "
    "important INTEGER, UNIQUE(cid, fid, pos0, pos1, owner))",
)

project、source、code_cat、code_name、code_text 五张表就是桌面版 .qda 的核心结构;code_text 上的 UNIQUE(cid, fid, pos0, pos1, owner) 保证同一段文字同一代码不会被重复编码。领域层总共 9 个公开函数,全部返回 dict / list 以便 JSON 序列化。

6.2 双通道自动降级

54 个 C 扩展里有 zlib、_json、_datetime、_socket、unicodedata,唯独没有 _sqlite3(也没有 _ssl)。于是设计成可插拔存储层:

def _manifest(path: Path) -> Path:
    return path / "qualcoder_mvp.json"

def _read_manifest(path: Path) -> dict[str, Any]:
    target = _manifest(path)
    if not target.is_file():
        return {"name": path.stem, "sources": [], "codes": [], "codings": []}
    try:
        return json.loads(target.read_text(encoding="utf-8"))
    except Exception:
        return {"name": path.stem, "sources": [], "codes": [], "codings": []}

def _create_sqlite_project(path: Path, name: str) -> bool:
    if sqlite3 is None:
        return False
    try:
        with sqlite3.connect(path / "data.qda") as conn:
            for statement in SCHEMA:
                conn.execute(statement)
            if conn.execute("SELECT COUNT(*) FROM project").fetchone()[0] == 0:
                now = _datetime.datetime.now().astimezone().isoformat(timespec="seconds")
                conn.execute("INSERT INTO project VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
                             ("ohos-mvp", now, "", name, 0, 0, "", "", None, None, None))
            conn.commit()
        return True
    except Exception:
        return False

def create_project(project_path: str, project_name: str = "") -> dict[str, Any]:
    path = Path(project_path)
    path.mkdir(parents=True, exist_ok=True)
    for folder in ("documents", "images", "audio", "video"):
        (path / folder).mkdir(exist_ok=True)
    data = _read_manifest(path)
    name = project_name or data.get("name") or path.stem
    data["name"] = name
    data["storage"] = "sqlite" if _create_sqlite_project(path, name) else "manifest"
    _write_manifest(path, data)
    return summary(str(path))

def _sqlite_ok(path: Path) -> bool:
    return (
        sqlite3 is not None
        and (path / "data.qda").is_file()
        and _read_manifest(path).get("storage") == "sqlite"
    )

所有读写先过 _sqlite_ok()。当前真机 storage == "manifest",走 qualcoder_mvp.json;补齐 _sqlite3 扩展后,业务代码一行不改就能切回真正的 data.qda,进而与桌面版项目文件互通。图 5 底部日志里 "storage":"manifest" 就是这条降级的现场证据。

6.3 区间编码:code_selection

def code_selection(
    project_path: str, code: str, source: str, seltext: str, pos0: int, pos1: int
) -> dict[str, Any]:
    """Attach a code to a text span [pos0, pos1) of a source document."""
    code = (code or "").strip()
    if not code:
        raise ValueError("code must not be empty")
    if pos0 < 0 or pos1 <= pos0:
        raise ValueError(f"invalid text span: [{pos0}, {pos1})")
    path = Path(project_path)
    if not path.is_dir():
        raise FileNotFoundError(f"not a QualCoder project: {path}")
    now = _datetime.datetime.now().astimezone().isoformat(timespec="seconds")
    if _sqlite_ok(path):
        with sqlite3.connect(path / "data.qda") as conn:
            code_row = conn.execute("SELECT cid FROM code_name WHERE name = ?", (code,)).fetchone()
            if code_row is None:
                conn.execute(
                    "INSERT INTO code_name (name, memo, catid, owner, date, color, supercid) "
                    "VALUES (?, '', NULL, ?, ?, '#3569a8', NULL)", (code, OWNER, now))
                code_row = conn.execute(
                    "SELECT cid FROM code_name WHERE name = ?", (code,)).fetchone()
            src_row = conn.execute("SELECT id FROM source WHERE name = ?", (source,)).fetchone()
            if src_row is None:
                raise FileNotFoundError(f"source not in project: {source}")
            conn.execute(
                "INSERT OR REPLACE INTO code_text "
                "(cid, fid, seltext, pos0, pos1, owner, date, memo, avid, important) "
                "VALUES (?, ?, ?, ?, ?, ?, ?, '', NULL, 0)",
                (code_row[0], src_row[0], seltext, pos0, pos1, OWNER, now))
            conn.commit()
    data = _read_manifest(path)
    codes = data.setdefault("codes", [])
    if not any(item.get("name") == code for item in codes):
        codes.append({"name": code, "memo": "", "date": now, "color": "#3569a8"})
    codings = data.setdefault("codings", [])
    codings[:] = [
        item for item in codings
        if not (item.get("code") == code and item.get("source") == source
                and item.get("pos0") == pos0 and item.get("pos1") == pos1)
    ]
    codings.append(
        {"code": code, "source": source, "seltext": seltext,
         "pos0": pos0, "pos1": pos1, "date": now}
    )
    _write_manifest(path, data)
    return {"ok": True, "code": code, "summary": summary(str(path))}

三件事:校验区间、必要时自动建代码、按四元组去重。manifest 分支用列表推导先删同键旧记录再追加,等价于 SQL 的 INSERT OR REPLACE。

6.4 回读:list_codings

def list_codings(project_path: str, code: str = "", source: str = "") -> list[dict[str, Any]]:
    """Codings, optionally filtered by code and/or source. Newest first."""
    path = Path(project_path)
    if not path.is_dir():
        raise FileNotFoundError(f"not a QualCoder project: {path}")
    result: list[dict[str, Any]] = []
    if _sqlite_ok(path):
        try:
            with sqlite3.connect(path / "data.qda") as conn:
                sql = ("SELECT cn.name, s.name, ct.seltext, ct.pos0, ct.pos1, ct.date "
                       "FROM code_text ct "
                       "JOIN code_name cn ON cn.cid = ct.cid "
                       "JOIN source s ON s.id = ct.fid")
                where: list[str] = []
                params: list[Any] = []
                if code:
                    where.append("cn.name = ?"); params.append(code)
                if source:
                    where.append("s.name = ?"); params.append(source)
                if where:
                    sql += " WHERE " + " AND ".join(where)
                sql += " ORDER BY ct.ctid DESC"
                for name, src, seltext, pos0, pos1, date in conn.execute(sql, params).fetchall():
                    result.append({"code": name, "source": src, "seltext": seltext,
                                   "pos0": pos0, "pos1": pos1, "date": date})
                return result
        except Exception:
            pass
    data = _read_manifest(path)
    for item in data.get("codings", []):
        if code and item.get("code") != code:
            continue
        if source and item.get("source") != source:
            continue
        result.append({"code": item.get("code", ""), "source": item.get("source", ""),
                       "seltext": item.get("seltext", ""), "pos0": item.get("pos0", 0),
                       "pos1": item.get("pos1", 0), "date": item.get("date", "")})
    result.reverse()
    return result

ArkTS 的 refreshCodes() 用 list_codings(path, '', selectedName) 拿到当前资料的高亮数据,再交给 buildChunks() 渲染。字段与 CodingEntry 接口一一对应,这就是跨语言契约的最后一环。


七、真机验收:从冷启动到编码闭环

编码高亮与持久化

图 5:真机编码实拍。载入内置示例访谈 sample_interview.txt 后,在右栏新建代码 go,切到选取模式拖选句子并编码;中栏「以前晚上能安安静静备课改作业,现在一半时间在应付检查材料。」已带彩色高亮,底部状态显示「当前资料已编码 1 段 · 分块 3」,右栏编码记录同步出现。底部日志栏还打出了 summary 的 JSON:{..., "sources":0, "codes":1, "codings":1, "storage":"manifest"}——双通道降级与「载入 ≠ 入库」两个设计在此同框可见。

7.1 五步动线

步操作期望
1冷启动日志依次:运行时解压 / home … / CPython 3.12 已连接 / MVP_CORE_SUCCESS;三栏完整渲染
2载入示例访谈中栏显示全文,右栏代码列表刷新,无 ForEach 报错
3导入当前资料左栏 资料 N +1,documents/sample_interview.txt 落盘
4加两个代码 → 拖选 → 编码自动回查看模式、彩色高亮出现、编码记录新增、统计递增
5点高亮 / 撤回 / 清空 / 重启状态栏提示代码名;统计同步;重启后代码与编码全部恢复

7.2 边界与验收矩阵

维度检查点
运行时首启解压、二次复用、os.py 校验、marker 内容匹配
内核runTest 全绿、Python 版本打印、traceback 可在日志栏看到
文件picker 授权、7 种后缀、UTF-8 / GB18030、超长文本、空文件
代码本新增、重复名、删除级联、选中 / 取消、计数
编码空选区拦截、空代码名拦截、重复区间去重、超界区间过滤、撤回、批量清空
渲染相邻 / 重叠区间分块、6 色高亮、点击查看、分块数提示
持久化manifest 落盘、summary.storage、重启恢复、documents/ 原文一致
系统零权限、窗口缩放、2in1 / 平板布局、无 native 崩溃

排障口诀:任何「看起来没反应」的问题,先看底部日志栏。这一版把 Python 回包和 traceback 原样显示,绝大多数故障第一次点击后就能定位,不必连调试器。


八、上架工程实践:构建、签名、脱敏与合规

8.1 应用配置

AppScope/app.json5:

{"app": {"bundleName": "org.qualcoder.native", "vendor": "qualcoder",
         "versionCode": 1020000, "versionName": "1.2.0",
         "icon": "$media:app_icon", "label": "$string:app_name"}}

entry/src/main/module.json5 只有一个 Ability,且零权限:

{
  "module": {
    "name": "entry",
    "type": "entry",
    "mainElement": "EntryAbility",
    "deviceTypes": ["2in1", "tablet"],
    "pages": "$profile:main_pages",
    "abilities": [{
      "name": "EntryAbility",
      "srcEntry": "./ets/entryability/EntryAbility.ets",
      "exported": true,
      "skills": [{ "entities": ["entity.system.home"],
                   "actions": ["action.system.home"] }]
    }],
    "requestPermissions": []
  }
}

EntryAbility 薄到只有一句 loadContent:

onWindowStageCreate(windowStage: window.WindowStage): void {
  hilog.info(0x0000, DOMAIN, 'onWindowStageCreate');
  windowStage.loadContent('pages/Index', (err) => {
    if (err.code) {
      hilog.error(0x0000, DOMAIN, 'loadContent failed: %{public}s', JSON.stringify(err));
      return;
    }
    hilog.info(0x0000, DOMAIN, 'loadContent succeeded');
  });
}

没有后台服务、没有自定义 URI scheme、没有 fileProvider、没有对外 IPC,runCode 也没有暴露给任何其他应用。

8.2 构建命令

$env:DEVECO_SDK_HOME = 'C:\Program Files\Huawei\DevEco Studio\sdk'
$env:JAVA_HOME       = 'C:\Program Files\Huawei\DevEco Studio\jbr'
$env:PATH            = "$env:JAVA_HOME\bin;$env:PATH"
cd ohos_hap
ohpm install --prefix entry
& 'C:\Program Files\Huawei\DevEco Studio\tools\hvigor\bin\hvigorw.bat' `
  --mode module -p module=entry@default -p product=default assembleHap --no-daemon

两个环境坑:命令行构建必须设 DEVECO_SDK_HOME,且把 DevEco jbr\bin 前置到 PATH,否则 PackageHap 阶段 spawn java ENOENT(00308018);compatibleSdkVersion 要与 targetSdkVersion 对齐(本项目 6.0.1(21) / 6.0.2(22)),误写 compatibleSdkVersionStage 或升到 beta 会让 import lazy 报 10705000。

8.3 签名材料脱敏(开源前必做)

本机 build-profile.json5 原本长这样(已脱敏示意,切勿提交真实值):

"signingConfigs": [{
  "name": "default",
  "type": "HarmonyOS",
  "material": {
    "storeFile": "C:/Users/<user>/Downloads/p12.p12",
    "storePassword": "<加密口令>",
    "keyAlias": "<alias>",
    "keyPassword": "<加密口令>",
    "signAlg": "SHA256withECDSA",
    "profile": "C:/Users/<user>/Downloads/xxxRelease.p7b",
    "certpath": "C:/Users/<user>/Downloads/public.cer"
  }
}]

推到公开仓库的版本改成:

{
  "app": {
    "products": [{
      "name": "default",
      "compatibleSdkVersion": "6.0.1(21)",
      "targetSdkVersion": "6.0.2(22)",
      "runtimeOS": "HarmonyOS",
      "buildOption": { "nativeLib": {
        "collectAllLibs": true,
        "debugSymbol": { "strip": false }
      }}
    }],
    "signingConfigs": []
  },
  "modules": [{
    "name": "entry", "srcPath": "./entry",
    "targets": [{ "name": "default", "applyToProducts": ["default"] }]
  }]
}

同时新增 .gitignore 挡住生成物与本机配置:

# 本机签名材料、IDE 配置与 SDK 路径(严禁入库)
local.properties
.idea/
.clangd
.clang-tidy
*.p12
*.p7b
*.cer
*.dmp

# hvigor / ohpm / CMake 生成物
.hvigor/
oh_modules/
entry/oh_modules/
**/.cxx/
**/build/
**/outputs/
node_modules/

# 构建产物
*.hap
*.app
*.hsp
*.har

# 日志
*.log

这一步把约 300 MB 的 build/、.idea/、entry/build/ 挡在仓库外,最终开源 300 个文件、可完整编译。克隆者只需在 DevEco 里对 org.qualcoder.native 做一次自动签名即可出包——签名 profile 与 bundleName 绑定,换包名必须先换签名材料,否则安装会报 9568289 类错误。

8.4 体积拆解

组成体积
libpython3.12.a(whole-archive 整链)33.32 MB
ohos_python_stdlib.zip4.39 MB
54 个 C 扩展 .so~10 MB
CPython 头文件 / ArkTS / 图标 / 界面留证少量
签名 HAP38.78 MB(unsigned 38.12 MB)

一个自带完整 Python 3.12 解释器、能跑真实 Python 领域代码的应用,最终 38.78 MB——这是"只搬契约、不搬 161 个模块"的直接收益。

8.5 上架前技术自查

检查项本工程状态
权限最小化requestPermissions: [],无 ACL、无网络
外部文件访问仅 DocumentViewPicker 用户授权,数据只进沙箱
不 exec 外部二进制无 fork / exec / popen / system
不动态下载代码运行时随 HAP 分发,PYTHONDONTWRITEBYTECODE=1
敏感资料仅落 filesDir,无外发通道(清单明文存储需后续加密)
包体与启动38.78 MB,首启解压一次,marker 后秒进
崩溃可观测底部日志栏 + traceback + hilog
开源合规签名材料已清空、生成物已忽略;LICENSE 与上游 LGPL-3.0 需统一

最后一条要如实说明:仓库初始的 LICENSE 是 MIT 占位模板、oh-package.json5 写着 "license": "MIT",而上游 QualCoder 是 LGPL-3.0。发布时应统一协议并补全版权行,别留模板默认值。


九、软件迁移能力自评(T0 / T1 / T2)

按能力覆盖口径给结论,状态以鸿蒙 PC 真机实测为准(能用 / 未适配,未适配是按平台特点主动分期,不是缺陷)。

T0 基础能力

能力状态依据
编译打包成 HAP能用DevEco 与 hvigorw 均可,已出签名包
签名 / 安装 / 启动 / 不白屏能用应用市场已上架,三栏完整渲染
运行时首启部署 + 二次复用能用zip → filesDir/ohos_python,marker 幂等
静态 CPython 3.12 进程内初始化能用Py_InitializeEx(0) + 单例 mutex
54 个 C 扩展 dlopen能用HAP libs 路径 + RTLD_GLOBAL + DT_NEEDED 补丁
内核自检 / 异常回传能用MVP_CORE_SUCCESS,traceback 可在日志栏读
零权限运行能用requestPermissions: []

T0:100%

T1 日常核心能力

能力状态
项目创建 / 统计能用
外部文本载入(7 后缀、中文自适应)能用
资料导入与原文落盘能用
代码本 CRUD / 选中 / 计数能用
区间编码 / 去重 / 校验能用
高亮渲染 / 点击查看能用
编码记录 / 单条撤回 / 批量清空能用
重启持久化能用

T1:100% —— 载入 → 导入 → 建代码 → 选取 → 编码 → 高亮 → 撤回 → 重启恢复,端到端闭环。

T2 增强能力

能力状态说明
真 data.qda / 桌面版互通未适配差一个 _sqlite3 扩展,优先级最高
多项目 / 代码树 / 分类未适配后续迭代
图片 / 音视频编码未适配依赖 VLC / ffmpeg 在 OHOS 的可行性评估
富格式导入(PDF / docx / epub)未适配依赖 pymupdf / EbookLib
报告 / 图表 / 词云未适配上游 report_*.py 十余个模块
AI / 向量检索 / MCP未适配云端能力还需单独声明隐私
轻量增强(按资料过滤、区间分块、6 色着色、编码计数)能用已内建

T2:约 5%

一句话总结:迁移深度上是 T1 引擎迁移级 + T0 级原生 ArkUI 壳;能力覆盖上 T0 100% / T1 100% / T2 约 5%。一个能在鸿蒙 PC 上真正做文本开放式编码的 QualCoder 精简版已经跑通并上架。


十、已知限制与后续路线

#问题影响建议
1缺 _sqlite3走 JSON 清单,不能与桌面版 .qda 互通交叉编译 _sqlite3 入 libs
2add_code SQLite 分支直接 INSERT重复名触发 UNIQUE;manifest 分支不暴露改 INSERT OR IGNORE
3选区是 UTF-16 code unit,Python 存 code pointemoji / 代理对文本位置可能偏移桥接层统一按 Unicode scalar
4固定单项目 mvp.qda无法新建 / 切换项目列表 + 目录 picker
5pyQuote 未处理 \r / U+2028极少数文本可能拼出异常字面量结构化 RPC
6runCode 可执行任意 Python 字符串一旦有外部输入即形成注入面改 callFunction(name, args) + 白名单
7qualcoder_mvp.json 明文存访谈原文定性资料常含敏感信息加密 + 隐私声明
8代码颜色 6 色哈希 / 重叠区间先到先得撞色、一段贴两码只显示第一个持久化颜色、多重编码叠加
9refreshCodes 一次发 4 条 Python;UI 线程同步执行调用数偏多、超大文本可能卡顿合并 snapshot()、迁 Worker
10LICENSE 与上游协议不一致合规风险统一协议并补全版权行

阶段路线:P0/P1(运行时与无头内核)✅ → P2(编码闭环)✅ → P2.5 真 data.qda → P3 媒体能力 → P4 工作台完善 → P5 可选 AI;横切项包括结构化 RPC、Worker 化、清单加密。


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

  1. 准备 DevEco Studio 与 HarmonyOS SDK 6.0.1(21) / 6.0.2(22),设好 DEVECO_SDK_HOME、JAVA_HOME,jbr 前置 PATH。
  2. git clone https://atomgit.com/OpenHarmonyPCDeveloper/ohos_QualCoder,确认三份真相源:Index.ets、ohos_python_stdlib.zip、领域层 ohos_mvp.py(本地工作区 _upstream/QualCoder/src/qualcoder/)。
  3. 确认 cpp/prebuilt/libpython3.12.a(33.32 MB)与 cpp/python_inc/ 在位;entry/libs/arm64-v8a 有 54 个 .so,且目录内没有杂文件。
  4. (可选)python tools/build_runtime.py 重建 zip → python tools/extract_dynload.py 抽扩展 → python tools/patch_dynload_needed.py 补 DT_NEEDED。
  5. 若改过运行时内容,递增 Index.ets 的 RUNTIME_MARKER。
  6. ohpm install --prefix entry。
  7. DevEco 打开工程,对 org.qualcoder.native 自动签名(仓库版本 signingConfigs 为空)。
  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. 出图留证:空工作台、示例载入、选取模式、编码高亮、多代码 / 关于。

十二、常见问题(Q&A)

按主题分成五组,全部按工程源码与鸿蒙 PC 真机实测回答。与正文重合的部分只给结论并指明章节,不重复展开。

12.1 路线与选型

Q1:为什么不套个兼容容器(T2),把桌面版 QualCoder 原样跑起来?

因为上游的依赖链在 aarch64-ohos 上根本不成立:PyQt6 ≥ 6.5 没有对应的平台插件,pymupdf、python-vlc、EbookLib、pandas / matplotlib / plotly、langchain / faiss-cpu / transformers 等数十个包也没有可用轮子。真走 T2,等于要在 HAP 里再塞一套 Linux 用户态 + X/Wayland + Qt,包体数百 MB、启动以分钟计,且窗口管理、输入法、文件授权都受容器适配进度限制。本项目用 341 行纯标准库代码复刻数据契约,反而更轻(详见第二章)。

Q2:那为什么不索性把 161 个模块全量重写成 ArkTS(纯 T0)?

"161 个模块"里绝大部分是 GUI、报告、AI,真正不可轻易重写的是 .qda 的表结构与 (code, source, pos0, pos1) 编码语义。全量重写要再造 SQLite 兼容层、十几种文档解析器与图表生态,工作量与回归风险都不可控;而界面层重写只要 748 行 ArkTS。所以按价值切两层:领域层复用契约(T1),界面层原生重写(T0),不是非此即彼。

Q3:同样内嵌 CPython,ohos_Optiland 为什么放弃 T1,本工程却保留了?

差别在交互频率与单次计算量(第二章有对照表)。光学设计要求"改一个曲率半径立刻看光路",几十毫秒的跨语言开销就会打断编辑循环;定性编码是"每几秒一次人工点击、一次点击写一条记录",亚毫秒的字典与文件操作,几百毫秒延迟人类无感。T1 不是通用的坏路线,只在高频率即时反馈场景下才是。

Q4:这个应用到底算 T0 还是 T1?

看用哪套口径:

口径结论
迁移深度(怎么搬过来的)T1 引擎迁移级(携带 CPython 运行时 + NAPI 桥),但 UI 壳是 T0 标准
能力覆盖(能用了多少)T0 基础 100% / T1 日常 100% / T2 增强约 5%(第九章)

两套编号含义不同,读其他鸿蒙移植文章时先确认作者用的是哪一套,别混读。

12.2 运行时与内核

Q5:为什么不用 python3.12 可执行文件 + 子进程?

三个原因叠加:HarmonyOS 沙箱禁止 exec 外部二进制、filesDir 带 noexec 语义;OHOS 社区移植的定制 libpython 在 fork 子进程里 Py_BytesMain / Py_Main 会"空跑"(返回 0、不执行 argv 脚本);且 io 层写盘静默丢失,只有 os.write(1, …) 直通系统调用可靠。本项目绕开整条链路:Py_InitializeEx(0) 一次常驻,解释器就是应用进程里的一段代码,不 fork、不 exec、无 IPC(第四章)。

Q6:为什么 stdout/stderr 用文件捕获,而不是管道?

文件方案简单、可读、可留证,且规避了子进程 io 层不可靠的问题。C++ 把业务代码缩进后包进 _Capture 模板,sys.stdout / sys.stderr 指向 py_capture_out.txt / py_capture_err.txt,finally 里 flush + close,再拼成带 --- stdout --- / --- stderr --- 分隔符的报告。代价是每次调用两次小文件读写,在本工程的调用频率下完全无感。

Q7:54 个 C 扩展为什么必须放进 entry/libs/arm64-v8a/,不能留在 zip 里?

HarmonyOS 链接器命名空间禁止对应用沙箱 filesDir(el2)里解压出的 .so 执行 dlopen。所以扩展必须随 HAP 的 libs(el1 bundle 路径)分发,C++ 侧用 dladdr 推自身目录、math…so 当探针,并 dlopen(self, RTLD_GLOBAL | RTLD_NOLOAD) 提升符号域,最后用 lief 给每个扩展补 DT_NEEDED libqualcoder_native.so。少任何一步,表现都是"解释器起来了但 import math 全崩"(第三章)。顺带记住:libs 目录里只能有 .so,collectAllLibs: true 会把杂文件一起打进包。

Q8:改了 ohos_mvp.py 并重打包,为什么代码看起来完全没生效?

十有八九是运行时 marker 没递增。首启判据是:

const RUNTIME_MARKER = 'READY_QUALCODER_P1_0_0_2';
const marker = `${this.pythonHome}/.${RUNTIME_MARKER}`;
const osPy = `${this.pythonHome}/lib/python3.12/os.py`;
if (!openable(osPy) || !exists(marker) || readText(marker).indexOf(RUNTIME_MARKER) < 0) {
  // 解压 zip 并写 marker
} else {
  this.log('复用已部署运行时');   // ← 改动被旧沙箱文件吃掉
}

改 zip 内容后必须同步递增这个常量(或清应用数据重装),否则应用会一直复用上一次解压出来的旧 .py。

Q9:38.78 MB 的包体还能再小吗?

大头是 libpython3.12.a(33.32 MB,--whole-archive 整链)、stdlib zip(4.39 MB)与 54 个扩展(约 10 MB)。可行方向:裁剪 stdlib、按实际用到的模块保留扩展、对解释器做 -Os + --gc-sections、开启 strip。但每一条都在削"真实 CPython"的保真度,且与保留 strip: false 便于真机抓栈的初衷冲突,需要按发布目标权衡。

12.3 数据与持久化

Q10:summary().storage 为什么是 manifest 而不是 sqlite?怎么切回真 .qda?

54 个扩展里有 zlib、_json、_datetime、_socket,但没有 _sqlite3(也没有 _ssl)。领域层用一条集中判据自动降级:

def _sqlite_ok(path: Path) -> bool:
    return (
        sqlite3 is not None
        and (path / "data.qda").is_file()
        and _read_manifest(path).get("storage") == "sqlite"
    )

所有读写先过它,所以只要把交叉编译好的 _sqlite3.cpython-312-aarch64-linux-ohos.so 放进 entry/libs/arm64-v8a/,create_project() 就会建出真的 data.qda,业务代码一行都不用改(第六章)。图 5 日志里的 "storage":"manifest" 就是当前降级的现场证据。

Q11:生成的 data.qda 能和桌面版 QualCoder 直接互开吗?

当前不能(还没生成真 .qda)。表结构已按上游 5 张表定义、字段也对齐,但 P2.5 完成后还需验证两处语义:ArkTS 选区是 UTF-16 code unit、Python 存的是 Unicode code point(中文 BMP 一致,emoji / 代理对会错位),以及 code_text 的 owner / fid 映射。做完这两项才算真正互通。

Q12:杀掉进程重启后,代码和编码为什么还在?

启动时 prepareRuntime() 成功后依次调用 ensureProject()(create_project 幂等,目录与 manifest 已存在则不重建)和 refreshCodes()(重拉 list_codes / list_codings / list_codings(path,'',selectedName) / summary),把四类 @State 从沙箱重新灌回界面。所有数据都在 filesDir/QualCoderProjects/mvp.qda/,不依赖内存态。

Q13:载入示例访谈后,左栏 sources 为什么还是 0?支持哪些资料格式?

因为载入 ≠ 入库:loadSampleText() / pickText() 只把文本放进 selectedText,要点「导入当前资料」才调 import_text 写进 documents/ 并登记。这个设计让"先浏览、再决定入库"成为可能,也是图 5 里 sources: 0, codes: 1, codings: 1 的由来。当前支持 .txt/.md/.csv/.html/.htm/.tex/.rtf 七种纯文本后缀,UTF-8 优先、检测到 \uFFFD 回退 GB18030;PDF / docx / epub 与图片音视频属于后续阶段。

12.4 UI 与交互

Q14:编码高亮为什么用 Span.textBackgroundStyle,而不是 backgroundColor?

Span 不是普通组件,.backgroundColor() 会被静默忽略——不报错、也不生效。同类还有:属性链里调用私有方法(如 .fontColor(colorForCode(...)))同样被忽略。所以颜色与 bold 必须在 buildChunks() 里预计算进 TextChunk 字段,属性链只读字段:

Span(chunk.text)
  .fontWeight(chunk.bold ? FontWeight.Bold : FontWeight.Normal)
  .textBackgroundStyle({ color: chunk.color, radius: 4 })

Q15:为什么点「编码选中文字」时,刚拖好的选区会莫名消失?

点按钮会让 TextArea 失焦,触发一次 onTextSelectionChange(start == end);如果无脑写状态,就把选区覆盖成空了。修法是只在非空选区时更新,模式切换时才显式重置:

.onTextSelectionChange((start: number, end: number) => {
  if (end > start) {
    this.selStart = start;
    this.selEnd = end;
  }
})

Q16:为什么 add_code 一返回界面就闪退?

add_code / delete_code 返回的是 dict {ok, name, codes},早期整个赋给 @State codeEntries: CodeEntry[],ArkUI 抛 data source dose not have forEach function(103806)并杀进程。修法是取 .codes + Array.isArray() 校验;所有 JSON.parse 结果赋给数组状态的地方都要加这层防护。这条与 Q14、Q15 合起来就是第五章那"三个会杀进程的静默坑"。

Q17:零权限是怎么读到用户桌面上的文件的?

module.json5 的 requestPermissions: [],不申请任何存储权限。外部文件靠 DocumentViewPicker.select() 由用户当场授权返回 uri,再 fileIo.openSync(uri, READ_ONLY) → statSync(file.fd) → readSync(file.fd, buffer) → closeSync(file)。注意 openSync 返回的是 File 对象,读写要传 .fd,util.TextDecoder 要 Uint8Array。授权是一次性的,数据只进应用沙箱,不会写回原路径。

12.5 构建、上架与安全

Q18:克隆仓库后为什么构建不出签名包 / 装不上真机?

因为仓库版本的 build-profile.json5 故意把 signingConfigs 清空了——原配置含本机 p12 / p7b / cer 的绝对路径与加密口令,绝不能进公开仓库(第八章有前后对比)。克隆后请在 DevEco Studio 里对 org.qualcoder.native 做一次「Signing Configs → 自动签名」。签名 profile 与 bundleName 绑定,换包名必须先换签名材料,否则安装报 9568289 类错误。

Q19:命令行构建报 spawn java ENOENT / 00308018,或 import lazy 报 10705000?

前者是 PackageHap 阶段找不到 java:

$env:DEVECO_SDK_HOME = 'C:\Program Files\Huawei\DevEco Studio\sdk'
$env:JAVA_HOME       = 'C:\Program Files\Huawei\DevEco Studio\jbr'
$env:PATH            = "$env:JAVA_HOME\bin;$env:PATH"

后者通常是 SDK 版本被自动签名改写:compatibleSdkVersion 要与 targetSdkVersion 对齐(本项目 6.0.1(21) / 6.0.2(22)),不要写 compatibleSdkVersionStage,也不要误升到 beta。

Q20:runCode 能执行任意 Python 字符串,这安全吗?

当前风险可控但不应长期保留:调用串全部由应用内部拼接,没有外部输入路径;Ability 只有 home skill,没有自定义 scheme、fileProvider 或对外 IPC,runCode 没有暴露给第三方。但两个已知短板是真实的——pyQuote() 只转义 \、'、换行,未覆盖 \r / U+2028;qualcoder_mvp.json 明文存访谈原文,而定性资料往往敏感。演进方向是结构化 RPC(callFunction(name, argsJson) + 函数白名单)与清单加密,两者都在第十章的横切路线里。

Q21:能申请网络权限接 LLM 做 AI 辅助编码吗?

技术上可以(_socket 扩展在包里),但这会直接打破当前"零权限、无外发通道"的隐私优势。访谈稿常含可识别个人信息,一旦走云端就必须单独做隐私声明、用户明示同意与数据最小化说明,不能默默上传。所以 AI 被放在 P5(可选),且明确要求"若走云端须单独声明隐私与权限"。

Q22:仓库 LICENSE 是 MIT,上游 QualCoder 不是 LGPL-3.0 吗?

这是平台初始提交的 MIT 占位模板遗留([year] [fullname] 未填),oh-package.json5 也写着 "license": "MIT",与上游 LGPL-3.0 不一致。本工程新增的 ArkTS / C++ / ohos_mvp.py 与上游源码的授权关系需要明确,发布时应统一协议并补全版权行,别留模板默认值。这是第十章登记在册的合规项,不是代码缺陷。


结语

把一个 161 模块的 PyQt6 桌面软件搬上鸿蒙 PC,最容易走的路是"套个容器把原程序塞进去",最诱人的路是"把 Python 全量翻译成 ArkTS"。这次选了中间那条:只把不可轻易重写的数据契约带过来,界面按原生标准重写,中间用一层 326 行的 NAPI 桥把两边接住。

回过头看,真正花时间的不是写代码,而是四件事:搞明白 HarmonyOS 链接器命名空间为什么不让 dlopen 沙箱里的 .so;接受"不 fork、进程内常驻"这个更朴素的模型;把跨语言返回值收敛成"最后一行单行 JSON";以及发现 ArkUI 里那些不报错、只是静默不生效的属性。

上架页上的 5.0 分和 <1 万次 安装只是起点。仓库已经开源,欢迎提交 Issue 与 PR——尤其是 _sqlite3 扩展,那是解锁下一个能力层级最关键的一块拼图。

Logo

加入「COC·上海城市开发者社区」,成就更好的自己!

更多推荐