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

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 的版本化文档 |
更多推荐



所有评论(0)