笔记 | 把科学计算 IDE Spyder 搬到鸿蒙 PC:Qt C++ 宿主 + libpython embed 的三阶段实战

最近在折腾项目的时候碰到了这个知识点,查了不少资料,索性整理出来分享给大家。

把科学计算 IDE Spyder 搬到鸿蒙 PC:Qt C++ 宿主 + libpython embed 的三阶段实战

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

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

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

写在前面

本文要走一条完全不同的路:

  • 不在 Electron 里跑前端
  • 不在浏览器里跑 Python
  • 而是用 Qt C++ 宿主 + 内嵌 libpython 在鸿蒙 PC 上真真正正渲出一个 Qt Widgets 原生 GUI、一个原生 QTextEdit 编辑器、一个能调 Python 解释器跑的 Console,外加一个真实的 Variable Explorer。

一、为什么 Spyder 移植比想象的难

Spyder 上游是 Python 写的 PyQt IDE,发布形态是「Python 包 + Qt 应用代码 + 一堆 .ui/.py」。正常人在 PC 上是这么装它:

pip install spyder
spyder   # 启动

到鸿蒙 PC 上,三件事全部不成立:

1. 没有 Python 解释器(普通 HAP 应用沙箱里只有 ArkTS 运行时)
2. 没有 PyQtPyQt5/QtWidgets 那一套 native binding 要 CPython + Qt 头文件 + sip 编译器交叉编译)
3. 没有 X Server(Qt 5.x 默认走 XCB,离屏也得有平台抽象)

但 Qt for OpenHarmony 已经把"在 OHOS 上渲染 Qt Widgets"这条路打通——libqohos.so 是 Qt 的 OHOS QPA 平台插件,把 QWindow/QWidget 画到 OHOS 的 NativeWindow,并把 OHOS 的输入事件翻译成 QEvent。剩下要做的是把 Qt 应用本身 + Python 解释器塞进 HAP

这条路被 Thonny(另一个 Python IDE)走过一遍并验证可行,Spyder 在它的基础上加了三件事:

  • 三栏布局(Editor / Variable Explorer / Console)
  • F5 → 真实 Python 执行
  • Variable Explorer 展示运行后的真实全局变量

二、整体架构

四个层次的分工:

层职责ArkTS 薄壳Qt 应用启动入口;解压 stdlib;把 NativeWindow 接到 XComponentlibqohos.soQt 平台抽象:把 OHOS 原生 API 适配成 Qt 平台调用libspyder_shell.so业务宿主:Qt 渲染 + Python 子进程 + runner 协议libpython3.12.so真 CPython 解释器,跑在子进程里执行用户脚本


三、三阶段迭代

整个移植分三阶段,每一阶段在真机上独立可验收:

Phase 1:三栏 UI + Run 回显(最简链路)

目标:验证 Qt 嵌入 Python 跑通。

ArkTS QAbility → libqohos.so → libspyder_shell.so → fork → libpython3.12
                                                      stdout → Qt Console

跑通了 Run 命令能看到 hello 字样,就说明:Qt 平台抽象、Python 子进程、stdout 回流三条链路全部 OK。

Phase 2:F5 → Hello/count/Done + [Phase2] SUCCESS

目标:完整执行链 + 内嵌默认 demo 字符串。

so 里固化了一段 demo(方便第一次启动就有东西可跑):

name = 'HarmonyOS'
count = 3
items = [1, 2, 3]
mapping = {'a': 1, 'b': 2}
print('Hello from Spyder')
for i in range(count):
    print('count', i)
print('Done')

F5 → 把这段内容写到 <filesDir>/spyder_script.py → fork 子进程跑 → 抓 stdout。

Phase 3:Variable Explorer 真实变量 + 多标签/Open

目标:真正"能看到运行状态"。

引入 spyder_runner.py(so 内部嵌入):

# Runner 概要(伪代码)
src = open(sys.argv[1]).read()
g = {"__name__": "__main__"}
exec(compile(src, sys.argv[1], "exec"), g)

rows = []
for k, v in g.items():
    if k.startswith("__") or isinstance(v, types.ModuleType):
        continue
    rows.append({"name": k, "type": type(v).__name__,
                 "value": repr(v)[:300], "size": safe_len(v)})

print("SPYDER_VARS_BEGIN")
print(json.dumps(rows, ensure_ascii=False))
print("SPYDER_VARS_END")

子进程跑完用户脚本 → 输出 SPYDER_VARS_BEGIN/END 包裹的 JSON → C++ 侧 ExtractVarJson 抠出 → QTableWidget 渲染成 Variable Explorer。

底部状态栏的关键日志(看到就说明链路通):

[spyder-1.0.3-phase3-introspect-tabs] ready.
PYTHONHOME=/data/storage/el2/base/haps/entry/files/python
--- Run @ 16:45:08 (Phase3 introspect) ---
[spyder-bootstrap] stdout bound to fd=51
Hello from Spyder
count 0
count 1
count 2
Done
[Phase3] SUCCESS: Hello/count/Done observed.


四、ABI 铁律(必踩一坑)

HAP 内 Qt runtime 是 5.12.12(壳模板自带),本机交叉编译常用 Homebrew qt@5 5.15 头文件。禁止在业务 so 里使用 QString::arg(...)。

原因:QString::arg(...) 在 5.15 头里会展开为 QtPrivate::argToQString(QStringView,...),这个符号 5.12 的 libQt5Core 里没有 → 业务 so dlopen 进来时找不到符号 → SIGABRT

护栏:native 工程的 build_ohos.sh 会在产物里 grep argToQString,命中就 fail。

教训:跨大版本编译 Qt 业务代码,永远要 grep 头版本特有的符号。这是 Qt 5.12 → 5.15 / 5.15 → 6.x 迁移时最隐蔽的杀手。


五、nostrip iron law(动态 dlopen 链的硬约束)

libpython3.12.so 务必不 strip。原因:

// libspyder_shell.so 启动时:
dlopen("libpython3.12.so", RTLD_NOW);
Py_BytesMain(argc, argv);

strip 后的 libpython 会:

  • 丢失动态符号(Py_BytesMainPy_Main 等)
  • dlopen 失败 → SIGABRT
铁律build-profile.json5nativeLib.debugSymbol.strip=false;HAP 内 libpython3.12.so7063424 字节。验收时如果发现大小不对,立刻停止排查 dlopen 链。

六、真机验收:七个功能点逐一过

设备:HUAWEI MateBook Pro,HarmonyOS 7.0.0。安装产物 entry-default-signed.hap ≈ 478 MB(含 Qt runtime + libpython + stdlib zip + 全部 .so)。

6.1 DevEco 部署一键式

选择真机,Run 一次即装机启动,整套 Qt 资源 + libpython + stdlib 一并推到设备沙箱。

6.2 核心成功验收

(见 §三 Phase 3 图)—— demo.py 跑完,5 个真实变量 + [Phase3] SUCCESS。这是验收清单里唯一硬性指标

6.3 多标签 + 错误状态

QTabWidget 实现的标签页,左下红点表示该 tab 有未保存改动。运行时某次输入语法错,Console 显示完整 traceback,Variable Explorer 还能看到上一次成功运行的 globals——这点和真实 Spyder 一致。

6.4 说说弹窗(About)

About 弹窗文字本身就是项目核心约束的小抄:

  • Qt Widgets + embedded CPython 3.12 (libpython):技术栈
  • Same nostrip iron law: strip=false:复盘 ABI 铁律
  • Avoid QString::arg (Qt 5.15 ABI vs HAP 5.12 runtime):另一个 ABI 铁律
  • Success = Console shows Hello / count / Done + variable rows:唯一验收标准

七、剪贴板权限墙(额外发现)

移植做完后用户提了一个朴素问题:「为什么应用里 Ctrl+V 粘贴不了?」

以为是快捷键绑定问题,结果是平台权限墙

$ hdc shell "atm dump -t -b org.spyder.ide.ohos" | grep READ_PASTEBOARD
"permissionName": "ohos.permission.READ_PASTEBOARD",
"grantStatus": -1,            ← 未授权
"grantFlag": 0

READ_PASTEBOARDsystem_basic 级别的受限权限,普通 debug 签名(normal APL)的 HAP 装上后运行时授权直接被拒。

链路:

Ctrl+V
  → Qt QClipboard (QPlatformClipboard)
    → libqohos.so (qohosclipboardobject.cpp)
      → OH_Pasteboard_GetData()       ← 每次被权限拦截
        → 永远返回空
          → 剪贴板菜单可点,无内容可贴

module.json5 里其实已经声明了这个权限(这是正常做法),但声明 ≠ 授权。受限开放权限在普通应用上不会自动批准。

这是个对所有鸿蒙 PC 工程都通用的发现:任何 Qt/Qt-like 应用,只要用到了系统剪贴板读取功能,都面临同一堵墙


八、默认案例的"二级曲线救国"

另一个朴素问题:「能不能把默认的 demo.py 换成我想要的那段 SPYDER logo 代码?」

第一反应:改 so 内嵌字符串。

钻进 so(libspyder_shell.so)一看——demo.py 确实以 C 字符串常量形式嵌在二进制里:

"# Spyder for HarmonyOS PC — Phase 3 (introspect + tabs)\n
# Qt C++ shell · Ability + XComponent + QPA\n
# Run (F5): executes this file with CPython 3.12 and\n
# introspects the globals into the Variable Explorer.\n\n
name = 'HarmonyOS'\n
count = 3\n
items = [1, 2, 3]\n
mapping = {'a': 1, 'b': 2}\n
print('Hello from Spyder')\n
for i in range(count):\n
    print('count', i)\n
print('Done')\n"

但 native 源码(spyder_shell_main.cpp + build_ohos.sh)在本开发区里没保留,本地无法重编 so。改 so 二进制字符串要等长覆盖,新代码(含 emoji 🚀、box-drawing ╔═╗║╚╝)UTF-8 长度远超旧串,做不了原地替换。

绕路:ArkTS 侧的 SpyderPythonBootstrap.etsensureSpyderPythonRuntime 启动时执行——而 stdlib 解压完了大家就有了 filesDir 写权限。在那一步把 demo_case.py 的代码常量写到 filesDir/demo_case.py

const DEMO_CASE_PY = [
  'import time',
  'import os',
  '',
  'logo = r"""',
  ' ██████╗ ██████╗ ██████╗ ███████╗',
  // ... 用户给的整段代码
  'if __name__ == "__main__":',
  '    main()'
].join('\n');

function ensureDemoCase(filesDir: string): void {
  const target = `${filesDir}/${DEMO_CASE_NAME}`;
  if (readTextFile(target) === DEMO_CASE_PY) return;
  writeTextFile(target, DEMO_CASE_PY);
}

应用启动后用户 File → Open… 选择 demo_case.py → F5 → 效果如下:

Variable Explorer 自动捕获 logo (str, 229 字符)、clear (function)、main (function)——说明 Spyder 的 introspect 协议对任何合法 Python 脚本都生效。

这个"二级曲线"的可推广性:任何鸿蒙 PC 应用,如果遇到「功能在 so 里、不在源码里、改不了」的痛点,都可以问一句:ArkTS bootstrap 阶段能补一层吗? 能补就补。


九、踩坑对照表

现象根因处理启动 SIGABRT + argToQString5.15 头 + QString::arg用 1.0.3+;禁 .arg()build_ohos.sh ABI 检查仍是 Phase2 标题 / 旧回显未装 1.0.3卸载旧包再 Runstdlib not ready / missing os.py解压未完成等 5–15s 再 F5dlopen / SIGSEGVlibpython 被 strip查 HAP 内 so 是否仍 ≈7063424;strip=falseVariable Explorer 空runner 未产出 JSON看 Console 是否有 tracebackexit=0 但无 Hellobare exit不算成功Ctrl+V 无内容READ_PASTEBOARD grantStatus=-1受限权限,普通签名拿不到;暂无解想换默认 demodemo 嵌在 so 二进制改 ArkTS bootstrap 写 filesDir/demo_case.pylibpasteboard.so 缺失系统库找不到QPA 降级为空实现;复制/粘贴双重失效


十、已知限制(如实记录)

限制说明内嵌 demo 不可热替换demo 字符串固化在 so 二进制里;已用 ArkTS bootstrap 写 demo_case.py 绕路剪贴板读权限被拒READ_PASTEBOARD 是 system_basic 受限权限,普通 debug 包拿不到GPU 合成未开OHOS QPA 上 GPU 路径还在调,避免整页崩溃进程内 libpython 体积stdlib zip ≈ 14 MB,全部解压到 filesDir单 tab 内的 stdin 交互当前仅 stdout 回流(runner 一次性执行);无 REPL


十一、复现命令

# 1. 重编业务 so(需要 native 源码,本工程未保留)
cd /Users/zhubo/Downloads/harmony-pc/ohos_Spyder/native
./build_ohos.sh

# 2. 重打 HAP
cd /Users/zhubo/Downloads/harmony-pc/ohos_Spyder/harmony_pc
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

# 3. DevEco 自动签名后,检查并修正 products[].signingConfig = "default"

# 4. 安装
hdc install -r entry/build/default/outputs/default/entry-default-signed.hap
hdc shell aa start -a QAbility -b org.spyder.ide.ohos

# 5. 启动后等 5-15 秒(stdlib 解压),然后 Open → demo_case.py → F5


十二、给 Qt 移植工程的方法论

这套路径不只适用于 Spyder。任何"鸿蒙 PC 上的 Qt 应用 + 嵌入式脚本解释器"都可以照搬:

1. XComponent 是 Qt 进 OHOS 的正门——ArkTS 端薄壳只负责创建 NativeWindow 并交给 QPA,不做任何渲染
2. QPA 插件层是能力适配的"差量补丁"位——OHOS 没实现的 Qt 平台调用都在这里补(剪贴板、输入法、字体库)
3. embed 解释器走 fork + pipe + dlopen——比子进程更可控(能拿到 Py_BytesMain 入口),但路径上务必 nostrip
4. nostrip 是动态 dlopen 链的硬约束——HAP 内任何会被 dlopen 的 .so 都得 strip=false
5. 跨 Qt 版本编译永远先 grep 头版本特有符号——5.12→5.15、5.15→6.x,每个版本都有一批过时/新增的 inline 函数
6. ArkTS bootstrap 是二级曲线救国的好地方——任何"业务 so 里写死但改不了"的东西,都可以在 bootstrap 里"先 filesDir 写文件再让 so 用"
7. 平台权限墙摸清再动 UI——READ_PASTEBOARD 这种 system_basic 受限权限,写 UI 之前先在 atm dump -t -b 确认是否可达,否则剪贴板类功能做出来也是摆设

写完这篇回头看,Qt 应用在鸿蒙 PC 上跑通的核心不是 Qt 本身——Qt for OHOS 团队已经把大部分脏活干完了。真正的难点在于:ABI 跨版本、动态 dlopen 链的符号可用性、进程间 stdlib 共享、还有那些你想改但改不了的二进制资源。后两项这次都靠 ArkTS bootstrap 兜住了。

如果你也在做鸿蒙 PC 的 Qt 应用移植,欢迎拿这七个坑对照——大概率能少走一到两周的弯路。


常见问题 FAQ

Q1:启动后马上 F5,报 stdlib not readymissing os.py

首次启动时应用正在后台解压 14MB 的 stdlib zip 到沙箱,要 5–15 秒。等状态栏出现 ready. 再 F5。后续启动有 marker 文件跳过解压,秒开。

Q2:应用一启动就闪退?

九成是 ABI 断链:业务 so 里用了 QString::arg(),编译头是 5.15,HAP 里跑的 Qt 是 5.12,符号找不到直接 SIGABRT。检查 libspyder_shell.so 里是否残留 argToQString 符号,命中就回源码改掉重编。

Q3:F5 跑了,但 Variable Explorer 是空的?

看 Console 有没有 traceback——最常见是脚本本身语法错或运行时异常。只要 Console 打出了 SPYDER_VARS_BEGIN/END 包裹的 JSON,Explorer 就一定有内容;没有就是 runner 没跑完。

Q4:为什么编辑器里 Ctrl+V 粘贴不了代码?

READ_PASTEBOARD 是 system_basic 受限权限,普通 debug 签名应用授权直接被拒(grantStatus: -1),Qt 剪贴板桥接层每次读取都被拦。临时绕路:File → Open… 打开沙箱里的 .py 文件。

Q5:想把默认的 demo.py 换成自己的代码?

默认 demo 以 C 字符串固化在 libspyder_shell.so 二进制里,本地没有 native 源码改不了。已在 SpyderPythonBootstrap.ets 里内置了曲线方案:启动时自动把 demo_case.py 写到沙箱,Open… 打开即可。想换内容就改那个 ArkTS 常量重编 HAP。

Q6:怎么确认装的是新版本?

看窗口标题栏——Spyder (OHOS) — Phase 3 · introspect + tabs。如果还是 Phase 2 字样或输出旧回显,先卸载旧包再装,升级安装偶尔有元数据缓存。


暂时整理到这里。以上都是个人理解,可能有疏漏,欢迎指正。

评论 (0)

暂无评论