Spyder 移植鸿蒙 PC 实战——Qt C++ 宿主、CPython 3.12 嵌入与 Variable Explorer 三阶段打通

#鸿蒙PC   #HarmonyOS   #OpenHarmony   #Spyder   #Qt   #C++   #CPython   #Python   #跨平台移植   #IDE开发

把科学计算 IDE Spyder 搬到鸿蒙 PC,难点不只是“画出界面”,而是在 HAP 沙箱、Qt QPA、动态库 ABI 与 Python 运行时约束下,真正打通编辑、运行、回显和变量探查。本文以 Qt C++ 宿主 + CPython 3.12 为主线,从三栏 UI、F5 真执行到 Variable Explorer 真实变量回传,拆解 XComponent/QPA 渲染、标准库部署、fork + pipe + dlopen 执行链与 JSON 内省协议,并集中解决 Qt ABI、libpython strip、文件对话框等关键陷阱,最后给出可复现的构建命令、真机验收与分层排错清单。

30 秒先看结果:这不是一个“能打开窗口”的移植

如果把“移植 Spyder”理解为把 Python 包塞进 HAP、再找一个 python 可执行文件拉起来,第一步就会撞上平台边界。真正需要解决的是:Qt 窗口怎么进入鸿蒙 PC 的 Surface,CPython 从哪里加载,用户脚本如何执行,stdout/stderr 如何回到 Console,运行结束后又如何把真实 globals 变成 Variable Explorer。

本文的验收口径

不是“界面像 Spyder”,而是四条链同时成立:① Qt Widgets 三栏工作区稳定显示;② F5 触发真实 Python 执行并回传 print/traceback;③ 运行后右侧表格展示真实变量;④ New/Open/Save/Save As/Close Tab 可形成基本编辑闭环。

能力

表面现象

真正的验收点

Editor

多标签代码编辑区

当前文件可保存、打开与关闭;F5 运行的是当前编辑内容

Console

能看到文本

文本来自 CPython 子进程 stdout/stderr,而不是 C++ 写死的模拟输出

Variable Explorer

右侧有表格

变量来自 runner 的独立 globals,包含名称、类型、大小与值摘要

HAP 原生承载

应用能启动

ArkTS/XComponent/QPA/Qt Widgets 链路稳定,动态库依赖与 ABI 可重复构建

图 1  目标形态示意:真正需要闭环的是“编辑 → 执行 → 输出 → 变量状态”

一、先把边界说清:我们移植的是“工作流”,不是把上游包原样搬过去

上游 Spyder 的桌面形态建立在 Python、PyQt/Qt、spyder-kernels 与完整桌面运行环境之上。到了鸿蒙 PC,最关键的变化不是 UI API 换了,而是“桌面 Python 进程 + GUI 框架 + 文件系统”的默认假设不再成立:应用以 HAP 承载,运行时和文件系统都有沙箱边界,OpenHarmony QPA 也不是桌面 xcb/cocoa/windows 平台插件。

因此,本工程选择“Qt C++ 宿主 + 内置 CPython 3.12 运行时”的路线:ArkTS 只保留 Ability/XComponent 和首次运行时部署;真正的 IDE 工作区在 Qt Widgets;Python 不是依赖系统安装,而是作为 HAP 自带的 libpython3.12.so 与标准库一起交付。

一个容易误读的细节

“内嵌 libpython”不等于每次执行都必须在 Qt 主进程里直接 PyRun。当前工程为了隔离一次运行,会 fork 子进程,再由子进程 dlopen HAP 内的 libpython 并启动解释器。准确说法是:内置运行时、无外部 python 可执行文件、按次子进程执行。

1.1 三条路线为什么只剩一条工程上可控

路线

优点

主要阻力

结论

原样运行上游 PyQt/Spyder

功能最完整、与上游一致

缺少现成 PyQt 运行时与桌面 Python 进程模型;依赖链巨大

不作为当前首选

ArkTS 全量重写

平台原生、权限与组件最自然

IDE 编辑器、Console、变量内省、调试器、插件生态全部重做,工作量不可控

适合长期产品重构,不适合先跑通

Qt C++ 宿主 + CPython

保留 Qt 交互模型;可逐步补齐 Python 能力;HAP 内自包含

QPA/ABI/动态库/stdlib 部署需要工程化处理

当前最可控的折中

二、总体架构:把“渲染链”和“执行链”彻底拆开

图 2  整体架构:上半部分负责“把 Qt 窗口放进鸿蒙 Surface”,下半部分负责“把 Python 结果送回 IDE”

这套架构最重要的不是用了多少层,而是每层都能单独验收。窗口不出来时先查 XComponent/QPA,不要碰 Python;Qt 主界面已经稳定但 F5 没输出时,再查 fork/pipe/libpython;Console 正常但变量为空,才进入 runner/JSON 解析层。这样排错会从“全栈猜测”变成“逐层二分”。

2.1 四层宿主关系

层级

主要职责

失败时的典型现象

ArkTS / Ability

生命周期、XComponent 承载、首次解压 Python 标准库

应用能装但窗口未建立,或 stdlib 尚未就绪

libqohos QPA

把 Qt 窗口、输入与平台 Surface 对接

黑屏、窗口/输入/剪贴板行为异常

libspyder_shell.so

Qt Widgets 三栏 UI、菜单、编辑器、运行调度、JSON 解析

业务库 dlopen 失败、界面未出现、按钮无响应

libpython3.12.so + stdlib

解释器与标准库,执行用户脚本

dlopen/初始化失败、缺模块、脚本 traceback

2.2 当前工程基线与版本边界

项目

当前工程基线

需要记住的边界

应用 SDK

compatibleSdkVersion 6.0.1(21),targetSdkVersion 6.0.2(22)

换 SDK 时先看 API 与打包行为,不要顺手升级所有依赖

目标设备

tablet / 2in1,arm64-v8a;真机已在 MateBook Pro 验证

phone 暂不声明,避免把未解决的窗口问题带给用户

Qt 运行时

工程随包 Qt 5.12.12

重编业务 so 时必须与运行时 ABI 对齐

Qt for OpenHarmony 公开代码线

公开仓库主线可见 Qt 5.15.12 补丁

不要拿 5.15 头文件去假设 5.12 运行时具有同样符号

Python

CPython 3.12,libpython3.12.so

标准库与 lib-dynload 也必须是同一目标架构与版本

运行时资源

rawfile 中 spyder_python_runtime.zip,约 14 MB

首次解压到 filesDir,仅首次执行成本明显

当前 HAP

签名产物约 75 MB

尺寸随运行时与三方库扩展会继续增长

版本铁律

重编 Qt 业务库时,最稳妥的不是寻找“相近版本”头文件,而是让编译期头文件、库与 HAP 内运行时处于同一构建线;否则很可能本地链接成功、真机 dlopen 失败。

三、三阶段推进:为什么先做最短闭环,再做 IDE 功能

图 3  三阶段路线:每一阶段只增加一个新的不确定性

移植类项目最怕一次把 UI、Python、文件系统、变量内省、调试器全塞进来。任何一处失败,日志都会被多层噪声淹没。三阶段的价值在于:Phase 1 只确认 Qt 宿主;Phase 2 只确认解释器真实执行;Phase 3 才引入结构化变量回传和多标签文件操作。

四、Phase 1:先让 Qt Widgets 宿主稳定,再谈 Python

第一阶段的目标极克制:三栏窗口稳定显示,Editor、Console、Variable Explorer 都有清晰边界;Run/F5 先只做确定性回显。不要在这个阶段加载 libpython,因为你要证明的是 QPA、业务 so、Widgets 和事件循环都可靠。

4.1 三栏 UI 的最小骨架

结构化示例:Qt Widgets 三栏工作区

auto *root = new QSplitter(Qt::Horizontal, this);

auto *editorTabs = new QTabWidget(root);
auto *console = new QPlainTextEdit(root);
auto *vars = new QTableWidget(root);

console->setReadOnly(true);

vars->setColumnCount(4);
vars->setHorizontalHeaderLabels(
    QStringList() << "Name" << "Type" << "Size" << "Value");

root->addWidget(editorTabs);
root->addWidget(console);
root->addWidget(vars);
root->setStretchFactor(0, 6);
root->setStretchFactor(1, 3);
root->setStretchFactor(2, 3);

setCentralWidget(root);

这个阶段最好把每个区域都设置明确的 objectName 或日志前缀。原因很现实:后面输入、焦点、窗口尺寸、QPA 事件等问题出现时,你可以用日志精确知道“哪个区域收到了什么”。

4.2 F5 先做“假执行”,但验收必须可观察

connect(runAction, &QAction::triggered, this, [this] {
    console->appendPlainText("[Phase1] Run signal received");
});

Phase 1 验收

启动后能看到三栏;编辑器能输入;菜单与 F5 都能触发回调;Console 明确出现 [Phase1] Run signal received。到这里仍然没有 Python,这是刻意的。

五、Phase 2:把 CPython 3.12 真正塞进 HAP 的运行闭环

第二阶段开始处理真正困难的部分:Python 不是“一个 so”就结束了。解释器需要标准库、动态扩展、依赖 so、正确的 PYTHONHOME/PYTHONPATH,以及一个可以从 Qt 触发、可回收 stdout/stderr 的执行模型。

5.1 标准库为什么要首启部署

工程把 CPython 发行版压成 spyder_python_runtime.zip 放到 rawfile。首次启动时,ArkTS 用资源管理接口把 zip 落到应用沙箱,再通过 zlib 解压为 filesDir/python;校验 lib/python3.12/os.py 后写 marker,后续启动直接跳过大文件解压。这样既避免把数千个标准库小文件直接塞成资源项,也能让 CPython 拿到稳定的文件系统路径。

首启部署思路:rawfile → filesDir → 校验 → marker

// 伪代码:只表达流程,不绑定具体封装名称
async function ensurePythonRuntime(filesDir: string) {
  const marker = `${filesDir}/python/.spyder_runtime`
  if (exists(marker)) {
    writeText(`${filesDir}/spyder_python_home.txt`, `${filesDir}/python`)
    return
  }

  const zipBytes = await resourceManager.getRawFileContent("spyder_python_runtime.zip")
  const zipPath = `${filesDir}/spyder_python_runtime.zip`
  await writeBytes(zipPath, zipBytes)
  await zlib.decompressFile(zipPath, filesDir)

  assert(exists(`${filesDir}/python/lib/python3.12/os.py`))
  writeText(marker, "ready")
  writeText(`${filesDir}/spyder_python_home.txt`, `${filesDir}/python`)
}

这里的关键不是“能解压”,而是幂等。首启失败、用户强杀、磁盘不足时,都可能留下半成品目录。更稳的实现应当先解压到临时目录,通过 os.py 与关键动态模块校验后再原子切换 marker,避免把半部署状态误判为 ready。

5.2 为什么不 exec 沙箱里的 python3

当前工程的真机约束是 filesDir 不允许把随包展开的 python/bin/python3 当普通外部可执行文件去 execv。因此执行路线改为:解释器本体以共享库存在 HAP 的 native libs 中;每次运行由业务宿主 fork 一个子进程,子进程通过 dlopen 加载 libpython3.12.so,再解析 Py_BytesMain 入口启动解释器。

核心结构示例:fork + pipe + dlopen + Py_BytesMain

int runPythonFile(const QByteArray &pythonHome,
                  const QByteArray &runner,
                  const QByteArray &script,
                  QByteArray *captured)
{
    int fds[2];
    if (::pipe(fds) != 0) return -1;

    pid_t pid = ::fork();
    if (pid == 0) {
        ::dup2(fds[1], STDOUT_FILENO);
        ::dup2(fds[1], STDERR_FILENO);
        ::close(fds[0]);
        ::close(fds[1]);

        ::setenv("PYTHONHOME", pythonHome.constData(), 1);
        ::setenv("PYTHONUTF8", "1", 1);

        void *h = ::dlopen("libpython3.12.so", RTLD_NOW | RTLD_GLOBAL);
        if (!h) _exit(120);

        using PyBytesMainFn = int (*)(int, char **);
        auto pyMain = reinterpret_cast<PyBytesMainFn>(
            ::dlsym(h, "Py_BytesMain"));
        if (!pyMain) _exit(121);

        QByteArray a0("python3");
        QByteArray a1 = runner;
        QByteArray a2 = script;
        char *argv[] = {a0.data(), a1.data(), a2.data(), nullptr};

        int rc = pyMain(3, argv);
        _exit(rc);
    }

    ::close(fds[1]);
    *captured = readAllFromFd(fds[0]);
    ::close(fds[0]);

    int status = 0;
    ::waitpid(pid, &status, 0);
    return status;
}

为什么不是直接在 Qt 主线程里 PyRun_SimpleFile

直接嵌入主进程当然可行,但一次脚本里的 os._exit、native 扩展崩溃、全局解释器状态污染都更难隔离。按次子进程让 stdout/stderr 捕获、退出码与一次运行生命周期更清晰。代价是进程创建成本与 fork 语义需要额外评估。

5.3 一个容易被忽略的 POSIX 风险

从通用 POSIX 语义看,多线程进程 fork 之后,子进程在 exec 之前只能安全调用 async-signal-safe 函数;而 dlopen、内存分配、Python 初始化都可能触碰继承下来的锁。当前方案已在目标真机路径跑通,但产品化时仍应把“Qt 进程是否已有多线程、fork 发生时机、QPA/系统库锁状态”列为压力测试项。可选演进包括:更早创建专用执行 worker、使用平台允许的服务/进程隔离机制,或在可行时采用长驻解释器并以 IPC 传递任务。

5.4 用 Python 3.12 的 PyConfig 思路控制嵌入式运行时

CPython 官方文档对复杂嵌入场景更推荐 PyConfig / Py_InitializeFromConfig,因为它能显式控制 home、argv、site、环境变量和隔离模式。当前工程通过 Py_BytesMain 语义启动脚本简单直接;如果后续改成长驻解释器,建议优先使用 PyConfig_InitIsolatedConfig,把运行时路径与宿主环境切开。

生产化演进:用 PyConfig 明确控制嵌入解释器

PyStatus status;
PyConfig config;

PyConfig_InitIsolatedConfig(&config);
config.use_environment = 0;
config.site_import = 1;

status = PyConfig_SetString(&config, &config.home, pythonHomeW);
if (PyStatus_Exception(status)) goto fail;

status = Py_InitializeFromConfig(&config);
PyConfig_Clear(&config);
if (PyStatus_Exception(status)) goto fail;

这一步的价值不是“API 更新”,而是可重复性:宿主机器、用户环境变量、系统 Python 路径都不应该偷偷改变 HAP 内解释器的 import 结果。

5.5 Phase 2 的验收脚本要故意包含 stdout 与异常路径

print("Hello from Spyder")
for i in range(3):
    print("count", i)
print("Done")
print("[Phase2] SUCCESS")

✓ 正常路径:Console 必须按顺序出现 Hello、count 0..2、Done 与成功标记。

✓ 异常路径:故意写 1/0,Console 必须出现 Python traceback,而不是只得到一个非零退出码。

✓ 重复路径:连续运行多次,不能出现上一次运行的变量或缓存输出混入本次结果。

✓ 编码路径:输出中文与 emoji,验证 UTF-8 捕获与 Qt 显示链没有截断。

六、Phase 3:Variable Explorer 的本质是“建立一条状态协议”

图 4  Variable Explorer 数据链:用户代码与 IDE 之间用结构化协议传递状态

桌面 Spyder 依赖 IPython 内核维护会话并提供丰富内省。当前移植版本没有把整套内核搬进来,而是用一个更小的 exec-runner:把当前脚本 compile 后执行到独立字典 g,执行结束后筛选 g 中的用户变量,生成 JSON 快照,再用边界标记写回 stdout。C++ 侧只需要剥离标记块,其余文本仍然当普通 Console 输出。

6.1 runner:不要直接 json.dumps(globals())

真实变量可能包含文件句柄、模块、生成器、Qt/Python 扩展对象,repr() 甚至可能自己抛异常。一个能长期用的 runner 必须把“内省本身的失败”限制在单个变量,而不是让整个运行结果丢失。

更稳的 runner 骨架:单变量兜底 + 值截断 + 独立命名空间

from pathlib import Path
import json
import types

BEGIN = "SPYDER_VARS_BEGIN"
END = "SPYDER_VARS_END"

def safe_len(value):
    try:
        return str(len(value))
    except Exception:
        return ""

def safe_repr(value, limit=240):
    try:
        text = repr(value)
    except Exception as exc:
        text = f"<repr failed: {type(exc).__name__}>"
    if len(text) > limit:
        text = text[: limit - 3] + "..."
    return text

def describe(name, value):
    return {
        "name": name,
        "type": type(value).__name__,
        "size": safe_len(value),
        "value": safe_repr(value),
    }

script = Path(__import__("sys").argv[1])
source = script.read_text(encoding="utf-8")

g = {"__name__": "__main__", "__file__": str(script)}
exec(compile(source, str(script), "exec"), g, g)

records = []
for name, value in g.items():
    if name.startswith("__"):
        continue
    if isinstance(value, types.ModuleType):
        continue
    records.append(describe(name, value))

print(BEGIN)
print(json.dumps(records, ensure_ascii=False))
print(END)

边界行为必须提前定义

变量浏览器到底显示模块、函数、类、私有变量吗?大对象展示多少字符?len() 失败时怎么显示?repr() 执行很慢怎么办?这些不是 UI 细节,而是协议语义。越早固定,C++ 与 Python 两端越不容易互相猜。

6.2 C++ 端提取 JSON,不要破坏普通 Console 输出

bool extractVarJson(const QByteArray &all,
                    QByteArray *consoleText,
                    QByteArray *jsonPayload)
{
    const QByteArray begin("SPYDER_VARS_BEGIN\n");
    const QByteArray end("\nSPYDER_VARS_END");

    int b = all.indexOf(begin);
    if (b < 0) {
        *consoleText = all;
        return false;
    }

    int payloadStart = b + begin.size();
    int e = all.indexOf(end, payloadStart);
    if (e < 0) {
        *consoleText = all;
        return false;
    }

    *jsonPayload = all.mid(payloadStart, e - payloadStart);

    QByteArray left = all.left(b);
    QByteArray right = all.mid(e + end.size());
    *consoleText = left + right;
    return true;
}

解析后只更新表格,Console 保留纯用户输出

QJsonParseError err {};
QJsonDocument doc = QJsonDocument::fromJson(jsonPayload, &err);

if (err.error != QJsonParseError::NoError || !doc.isArray()) {
    console->appendPlainText("[vars] invalid JSON payload");
    return;
}

const QJsonArray rows = doc.array();
vars->setRowCount(rows.size());

for (int row = 0; row < rows.size(); ++row) {
    const QJsonObject o = rows.at(row).toObject();
    vars->setItem(row, 0, new QTableWidgetItem(o["name"].toString()));
    vars->setItem(row, 1, new QTableWidgetItem(o["type"].toString()));
    vars->setItem(row, 2, new QTableWidgetItem(o["size"].toString()));
    vars->setItem(row, 3, new QTableWidgetItem(o["value"].toString()));
}

当前“stdout 内嵌标记块”足以完成 Phase 3,但它有一个协议攻击面:用户代码如果恰好 print 同样的 BEGIN/END,解析器可能误判。更稳的下一版可以把变量 JSON 走第二条专用 pipe,stdout/stderr 只负责文本输出;这样控制面与数据面彻底分离。

6.3 Variable Explorer 的验收不要只看“表格有数据”

测试变量

预期类型

预期大小

预期值摘要

name = 'Harmony PC'

str

10

'Harmony PC'

items = [2, 4, 8]

list

3

[2, 4, 8]

mapping = {'phase': 3}

dict

1

{'phase': 3}

i(for 循环结束)

int

空

2

obj = object()

object

空

允许地址化 repr,但必须被长度限制

还应补两类反向测试:自定义 __repr__ 抛异常、对象的 __len__ 抛异常。变量浏览器应显示降级文本,而不是让 runner 在脚本已经成功后因为“查看变量”再次失败。

七、真正危险的坑 1:Qt ABI 不是“5.x 都差不多”

这个项目里最典型的启动即崩不是业务逻辑错,而是编译期和运行期 Qt 不一致:HAP 内运行时是 Qt 5.12.12,而开发机交叉编译如果用了 Qt 5.15 头文件,某些调用会生成对新私有符号的引用。业务 so 在你的构建机上能链接,到了真机加载旧 Qt 时却找不到符号,于是 dlopen 失败,最终表现成 QPA 或宿主启动 SIGABRT。

典型例子:QString::arg

某些 Qt 5.15 头文件路径会引入 QtPrivate::argToQString(QStringView, …) 一类符号;Qt 5.12 运行时没有它。表面看只是一个字符串格式化调用,实际上已经跨过了 ABI 版本线。

构建后门禁:不要等到真机崩了才查

# 业务 so 中不能出现目标运行时不存在的符号
llvm-nm -D libspyder_shell.so | grep -F "argToQString"

# 或者使用 readelf
readelf -Ws libspyder_shell.so | grep -F "argToQString"

# 检查依赖的 Qt so 名称
readelf -d libspyder_shell.so | grep NEEDED

短期护栏可以是“禁用已知会引出新符号的 API”,例如不用 QString::arg,改用 QString::number、拼接或安全的格式化方式;长期方案仍然是构建环境对齐。因为今天发现 argToQString,不代表明天不会在另一个 inline/private API 上重复踩坑。

八、真正危险的坑 2:libpython 的 strip 不能靠感觉

工程把 build-profile.json5 中 nativeLib.debugSymbol.strip=false 固化为硬约束,并用已知 libpython3.12.so 产物尺寸作为回归信号。真机实战里,错误的打包/strip 路径会导致 libpython 加载失败或崩溃。这里要补一个更严谨的工程判断:并不是所有 ELF strip 操作都会必然删掉动态导出表,因此“文件大小”只能做快速报警,不能替代符号检查。

# 先看入口是否还在动态符号表
readelf -Ws libpython3.12.so | grep -E "Py_BytesMain|Py_InitializeFromConfig"

# 再看共享库依赖是否齐全
readelf -d libpython3.12.so | grep NEEDED

# 若工具链提供 llvm-nm
llvm-nm -D libpython3.12.so | grep " Py_BytesMain$"

更可靠的验收方式

把“strip=false + 已知尺寸 + 关键导出符号存在 + 真机 dlopen 冒烟测试”组合成四层门禁。只盯尺寸会误报,只盯符号也看不到依赖 so 丢失。

九、文件操作:QFileDialog 不可用时,不要让“打开文件”拖死主链路

OHOS QPA 当前工程路径下没有可直接依赖的原生 QFileDialog,于是 Open… 改为自绘 QDialog + QListWidget,列出应用沙箱内文件;Save / Save As 使用输入对话框获取文件名并写入 filesDir。这个实现不华丽,但把一个重要产品边界说清楚:当前编辑器的可见文件范围就是应用沙箱。

操作

当前实现

边界

New file

新建内存文档并创建标签页

未保存时关闭必须有脏状态提醒

Open…

自绘列表选择沙箱文件

外部文件需先进入应用可访问范围

Save

写回当前路径

写失败必须显示 errno/路径,不能静默丢内容

Save As…

输入文件名,写入 filesDir

需要校验非法文件名、同名覆盖与编码

Close Tab

关闭当前标签

未保存内容必须二次确认

十、剪贴板与平台权限:Ctrl+V 失败时,问题可能根本不在 QTextEdit

桌面 Qt 的经验很容易让人先怀疑焦点、快捷键或 QTextEdit;但在鸿蒙 PC 路径里,QClipboard 最终要经过 QPA 调用系统 Pasteboard。某些环境下读取剪贴板涉及受限权限,普通调试应用不能只靠声明就获得系统级能力。于是现象会非常“像 UI Bug”:Ctrl+V 有按键事件,QClipboard 却返回空。

排查顺序应该从“Qt 是否收到快捷键”一路下钻到“QPA 是否真正读到平台剪贴板”,而不是在编辑器控件上反复改 eventFilter。对无法申请的权限,产品上应明确功能降级:例如只支持应用内剪贴板、提供导入文件入口,或在目标发行渠道允许的权限模型下重新设计。

十一、用五层检查点排错:把大系统拆成可证伪的小问题

图 5  五层故障定位:先确定失败发生在哪一层,再进入该层的日志与符号检查

11.1 安装/签名层

# 卸载旧版本,避免缓存与旧签名混淆
hdc uninstall org.spyder.ide.ohos

# 安装新产物
hdc install -r entry/build/default/outputs/default/entry-default-signed.hap

设备安装报 9568320 时,优先检查签名与 bundleName 是否匹配,并在 DevEco Studio 里重新生成当前机器的调试签名。不要先去怀疑 Qt。

11.2 QPA / 宿主加载层

• 黑屏:先确认 XComponent Surface 已建立、QPA 插件加载路径正确。

• 启动即 SIGABRT:先扫未解析 Qt 符号,尤其是业务 so 是否混入新版本头文件产生的引用。

• 界面仍是旧标题:先卸载旧版本再装,排除设备端旧 HAP 与缓存。

11.3 CPython 层

• stdlib not ready:确认首次解压是否结束,filesDir/python/lib/python3.12/os.py 是否存在。

• dlopen 失败:查 libpython 动态符号与 NEEDED 依赖,确认打包没有错误 strip。

• import 失败:打印 sys.prefix、sys.path、sys.version 与平台标识,确认 PYTHONHOME 指向部署目录。

• 脚本崩溃:先把 stderr 原样回传;不要把 Python traceback 折叠成“运行失败”。

11.4 协议层

Variable Explorer 为空时,不要一上来调 QTableWidget。先看 Console 原始输出里是否真的出现完整 BEGIN/END;如果有标记但解析失败,打印 JSON parse error 与 payload 前 200 字符;如果没有标记,说明用户脚本异常退出、runner 自己失败,或执行链根本没走到内省阶段。

十二、可复现构建:从 DevEco 到 hdc 的最短路径

当前工程把 Qt 运行时、CPython 发行版与必要 native so 一并放进项目,因此核心流程是 DevEco 同步、签名、构建、安装,不需要在目标机上再装 Python。

12.1 DevEco Studio

1. 用 DevEco Studio 打开包含 AppScope/ 与 entry/ 的工程根目录,等待 Sync 完成。

2. 进入 Project Structure → Signing Configs,启用 Automatically generate signature,生成当前机器可用的调试签名。

3. 连接鸿蒙 PC 真机或 2in1 / tablet 目标,选择 entry 模块运行。

4. 第一次启动等待标准库部署完成;后续启动通过 marker 跳过解压。

12.2 命令行构建

# macOS 仅作环境变量示例;Windows 请按实际 DevEco 安装目录调整
export NODE_HOME=/Applications/DevEco-Studio.app/Contents/tools/node
export DEVECO_SDK_HOME=/Applications/DevEco-Studio.app/Contents/sdk
export PATH=$NODE_HOME/bin:$PATH

hvigorw --mode module \
  -p module=entry@default \
  -p product=default \
  assembleHap --no-daemon

签名产物位于 entry/build/default/outputs/default/entry-default-signed.hap。当前工程 README 给出的典型产物约 75 MB;后续加入 NumPy、Matplotlib 等原生扩展后,体积、启动耗时与依赖 so 数量都会显著上升,应提前做依赖裁剪。

十三、真机验收:不要用“能启动”代替功能验证

序号

验收动作

必须看到的结果

01

冷启动应用

三栏 Editor / Console / Variable Explorer 正常出现

02

首次启动等待运行时部署

几秒后可运行;第二次启动不重复解压

03

打开 demo.py,按 F5

Console 出现 Hello、count 0..2、Done 与成功标记

04

观察 Variable Explorer

name / count / items / mapping / i 等真实变量可见

05

故意写 1/0 再 F5

Console 出现完整 traceback,应用本身不崩

06

Open 打开 demo_case.py

新标签出现,运行的是当前标签内容

07

New / Save / Save As / Close Tab

保存路径、脏状态与关闭行为一致

08

连续运行 20 次

无明显句柄泄漏、重复输出串台、僵尸子进程

09

重启设备后再运行

运行时路径、签名、标准库 marker 仍然有效

13.1 一份更像真实科学计算场景的验收脚本

from statistics import mean

samples = [2.1, 2.4, 2.2, 2.8, 2.5]
name = "sensor-A"
avg = mean(samples)
mapping = {"min": min(samples), "max": max(samples)}

print("dataset:", name)
print("count:", len(samples))
print("mean:", round(avg, 3))
print("range:", mapping)
print("[Phase3] SUCCESS")

这段脚本没有依赖 NumPy,却同时覆盖 list、str、float、dict、标准库 import、stdout 和变量浏览器。验收时右栏至少应出现 samples、name、avg、mapping,且值摘要与 Console 的计算结果一致。

十四、已知限制:不回避边界,反而能让架构更可信

当前限制

为什么存在

下一步

Console 不是 IPython

当前是按次 exec-runner,没有长驻会话与魔法命令

引入长驻 kernel/REPL 协议,区分代码执行与控制消息

Variable Explorer 只读

当前只做运行后快照

增加双向变量修改协议、类型校验与重跑策略

只有标准库

科学计算栈需要 aarch64-linux-ohos 原生扩展

交叉编译 NumPy/Matplotlib 及 BLAS/Freetype 等依赖

文件范围在沙箱

平台权限与文件选择能力尚未扩展

接入受支持的文件选择/导入机制

phone 未声明

存在窗口最小化恢复等适配问题

等 Qt for OpenHarmony 对应问题修复后再开放

fork 方案需压测

多线程宿主下 fork 后初始化大型运行时有通用 POSIX 风险

前移 worker 创建时机或引入专用服务/IPC

十五、从“能跑”走向“可维护”的五条工程原则

1. 先做最短闭环,再增加一类不确定性。UI、解释器、变量协议分三阶段,比一次集成更容易定位根因。

2. 把 ABI 当成发布契约。编译期头文件、运行期 Qt、libc++ 与系统 SDK 共同决定 native so 是否能被真机加载。

3. 把 Python 运行时当成一个产品组件,而不是一堆文件。版本、stdlib、lib-dynload、三方 so、PYTHONHOME 和 strip 策略要一起验收。

4. 把输出协议与用户输出分开。当前 marker 方案够用,下一步应把结构化控制消息移到独立 pipe/IPC,避免歧义。

5. 验收语义,不只验收退出码。脚本成功不等于 IDE 成功;必须同时验证 Console、Variable Explorer、文件状态和重复运行。

十六、下一步:如何补回一个科学计算 IDE 真正需要的能力

当三阶段闭环稳定后,再扩展功能会顺很多,因为新增能力可以依赖已经可靠的运行时与协议层,而不是继续和平台适配纠缠。

• 科学计算栈:优先打通 NumPy,再到 Matplotlib;每引入一个原生包,都要把其 .so 依赖图与目标 ABI 纳入自动检查。

• 交互式 Console:把按次 Py_BytesMain 演进为长驻解释器/内核,通过请求 ID、执行状态、stdout/stderr、结果对象组成消息协议。

• 图形窗格:Matplotlib 后端不应默认照搬桌面 QtAgg,先决定图像是离屏渲染、Qt canvas 还是单独窗口。

• 调试器:runner 增加 pdb/trace 事件,把断点、暂停、栈帧、局部变量设计成控制协议,而不是解析文本。

• 变量编辑:为 int/float/str/list 等安全类型提供受控写回;复杂对象仍保持只读,避免无边界序列化。

• 稳定性:增加连续运行、异常脚本、超大 stdout、无限循环、内存压力、中文路径、冷启动/热启动的自动化用例。

十七、结语:移植桌面 IDE 的关键,是把“默认存在的桌面能力”逐一显式化

把 Spyder 搬到鸿蒙 PC,真正有价值的并不是又做出一个三栏界面,而是把桌面系统里被我们习惯性忽略的能力逐个显式化:窗口如何拿到 Surface、Qt 如何适配平台、解释器从哪里来、标准库放在哪里、脚本如何被隔离执行、输出如何回传、状态如何结构化、ABI 如何在构建期被验证。

当这些边界被拆清楚,工程就不再是“碰巧在某台机器上跑起来”的 demo,而是一条可以继续演进的移植路线:Phase 1 证明宿主,Phase 2 证明执行,Phase 3 证明状态;之后再把 NumPy、Matplotlib、REPL、调试器和插件能力逐层接回去。

一句话收束

不要把目标定成“让 Python 出现在鸿蒙 PC 上”,而要定成“让一个 IDE 的编辑、执行、观察与文件工作流在新平台上保持可验证的一致性”。

附录 A:失败现象速查表

现象

优先原因

第一检查动作

处理方向

启动 SIGABRT,日志含 argToQString

Qt 头文件/运行时 ABI 混用

nm/readelf 扫业务 so 未解析符号

对齐 Qt 构建线;去除不兼容 API 后重编

界面仍是旧标题

旧 HAP 未卸载或缓存

hdc uninstall 后重装

确保安装的是当前 signed HAP

stdlib not ready / missing os.py

首启解压未完成或半部署

检查 filesDir/python 与 marker

重做幂等部署与校验

dlopen libpython 失败

打包/strip/依赖 so 问题

readelf -Ws/-d 检查关键符号与 NEEDED

保持 strip=false,补齐依赖

Console 有 traceback,变量表为空

脚本未执行到 runner 内省末尾

先修用户脚本异常

异常时变量快照为空属于预期

Console 正常,变量表仍为空

标记提取或 JSON 解析失败

打印 payload 与 parse error

修正协议边界/编码

Ctrl+V 无内容

QPA 到 Pasteboard 权限/实现边界

检查 QClipboard 与平台层返回值

做权限合规或功能降级

安装报 9568320

签名与 bundleName 不匹配

DevEco 重新自动签名

重构建 signed HAP

附录 B:参考资料

资料

用途

链接

ohos_spyder 工程仓库

当前鸿蒙 PC 适配工程、目录、版本与验收基线

打开官方/项目页面

Spyder 上游工程

了解原始 Spyder 架构与功能边界

打开官方/项目页面

CPython 3.12 Embedding

C/C++ 应用嵌入 Python 的官方入口

打开官方/项目页面

CPython 初始化 API

PyConfig、Py_InitializeFromConfig 等初始化机制

打开官方/项目页面

CPython sys.path 初始化

嵌入式运行时的路径与 home 说明

打开官方/项目页面

Qt for OpenHarmony

OpenHarmony SIG 的 Qt 适配代码与 5.15.12 补丁线

打开官方/项目页面

OpenHarmony 开发者文档

XComponent、NativeWindow、ArkUI 与系统 API 的版本化文档

打开官方/项目页面

Logo

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

更多推荐