鸿蒙PC迁移:Photoflare Qt 图片编辑器鸿蒙PC适配全记录
一、写在前面
欢迎加入鸿蒙PC开发者社区,共同打造开发者工具生态:鸿蒙PC开发者社区:https://harmonypc.csdn.net/
项目开源地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_Photoflare
欢迎在PC社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper
这篇文章记录的是 Photoflare 在 HarmonyOS PC / OpenHarmony PC 环境中的一次适配过程。
Photoflare 不是 Electron 项目,也不是一个 Web 前端应用。它是一个传统的 C++ / Qt Widgets 图片编辑器,桌面版本里包含菜单栏、工具栏、画布、标尺、调色板、工具面板、批处理、滤镜、文字工具、颜色选择、图像尺寸调整等典型桌面软件能力。
因此这次适配的重点不是把一个网页塞进 HAP,而是解决下面这些问题:
- 怎样让一个 Qt Widgets 桌面程序进入鸿蒙 Stage 模型。
- 怎样让
libentry.so作为 HAP native library 被启动。 - 怎样把 Qt Widgets 主窗口绘制到 ArkUI
XComponent上。 - 怎样把 Qt for Harmony 的 QPA 插件、Qt 动态库、UI 文件、qrc 资源和原项目 C++ 源码一起打包。
- 怎样处理 GraphicsMagick 这类桌面依赖在鸿蒙 arm64 环境下暂时不完整的问题。
- 怎样解决第一次运行白屏、弹窗挤压、按钮白底白字、工具栏选中态不明显这些真实设备问题。
- 怎样处理鸿蒙文件 URI,让从系统文件入口打开图片时也能传给 Qt。
本次适配采用的路线是:保留 Photoflare 原有 C++ / Qt Widgets 主体,新建 harmony_pc/ 作为鸿蒙工程壳。ArkTS 侧只负责 Ability、窗口和 XComponent;真正的界面和业务逻辑仍然由原来的 Qt 代码承担。
这套思路参考了已有 Qt for Harmony 项目的工程结构,例如 MoonPlayer 的鸿蒙 PC 适配方式,但没有直接复制 MoonPlayer 的业务代码。Photoflare 的包名、图标资源、CMake 源文件列表、启动参数、UI 修复和应用身份都按当前项目重新整理。

二、项目背景:Photoflare 是 Qt Widgets 图片编辑器
Photoflare 原项目主体大致如下:
photoflare-master/
├── src/
│ ├── main.cpp
│ ├── mainwindow.cpp
│ ├── mainwindow.ui
│ ├── HarmonyUi.cpp
│ ├── dialogs/
│ │ ├── NewDialog.cpp
│ │ ├── textdialog.cpp
│ │ ├── batchdialog.cpp
│ │ ├── gradientdialog.cpp
│ │ └── ...
│ ├── widgets/
│ │ ├── PaintWidget.cpp
│ │ ├── RulerWidget.cpp
│ │ ├── brushtypecombobox.cpp
│ │ └── colorboxwidget.cpp
│ ├── tools/
│ │ ├── PaintBrushTool.cpp
│ │ ├── TextTool.cpp
│ │ ├── PointerTool.cpp
│ │ └── ...
│ └── toolSettings/
├── Icons.qrc
├── IconsDark.qrc
├── Photoflare.pro
├── photoflare.pro
└── harmony_pc/
桌面版本本来通过 qmake 工程启动:
qmake Photoflare.pro
make
在鸿蒙上不能直接沿用普通桌面进程模型。HAP 的入口是 Stage 模型里的 UIAbility,窗口由鸿蒙系统创建。Qt 程序要进入这个窗口,需要通过 Qt for Harmony 的 OpenHarmony QPA 插件,把 Qt 绘制接到 ArkUI 的 XComponent 上。
所以本次新增了一个独立的鸿蒙工程壳:
photoflare-master/harmony_pc/
├── AppScope/
│ └── app.json5
├── build-profile.json5
├── entry/
│ ├── build-profile.json5
│ ├── src/main/
│ │ ├── cpp/CMakeLists.txt
│ │ ├── ets/
│ │ │ ├── abilitystage/MyAbilityStage.ets
│ │ │ ├── entryability/EntryAbility.ets
│ │ │ └── pages/Index.ets
│ │ ├── module.json5
│ │ └── resources/
│ └── hvigorfile.ts
├── hvigor/
├── oh-package.json5
└── qtforharmony_sdk/
这里的设计原则是:不重写 Photoflare,不复制一份业务代码,而是让原来的 Qt Widgets 代码在鸿蒙工具链下编译成 libentry.so。
三、鸿蒙工程壳:Ability + XComponent + Qt QPA
鸿蒙侧页面入口非常薄,核心是 Index.ets 中的 XComponent:
XComponent({
id: this.windowId,
type: XComponentType.NODE,
libraryname: 'plugins_platforms_qopenharmony'
})
.width('100%')
.height('100%');
这里的 libraryname 指向 Qt for Harmony 的 QPA 插件:
libplugins_platforms_qopenharmony.so
它的作用不是实现 Photoflare 的业务,而是把 Qt 窗口、输入事件、绘制输出接入鸿蒙窗口。
EntryAbility.ets 中的关键链路是:
import qpa from 'libplugins_platforms_qopenharmony.so';
private launchApplication = 'libentry.so';
await windowStage.loadContent(this.loadContentUrl, localStore);
qpa.handleJsTopWindowCreated(this.name, this);
qpa.startQtApplication(this);
这几个点很关键:
launchApplication指定真正要启动的 native Qt 应用库,也就是libentry.so。windowStage.loadContent()先加载包含XComponent的 ArkUI 页面。qpa.handleJsTopWindowCreated()把鸿蒙顶层窗口交给 Qt QPA 插件。qpa.startQtApplication()启动 native 侧 Qt 程序。
应用身份也换成 Photoflare 自己的配置:
{
"app": {
"bundleName": "io.photoflare.photoflare",
"vendor": "photoflare",
"versionCode": 1070100,
"versionName": "1.7.1",
"icon": "$media:layered_image",
"label": "$string:app_name"
}
}
这里有一个适配经验:借鉴其他 Qt for Harmony 项目的 wrapper 时,不能把别人的包名、图标、Ability label、签名材料和业务路径直接照搬过来。工程结构可以参考,但应用身份必须属于当前项目。

四、CMake 打包策略:把 Photoflare 编译成 libentry.so
鸿蒙 native 侧构建入口在:
harmony_pc/entry/src/main/cpp/CMakeLists.txt
它从 harmony_pc 反向定位 Photoflare 根目录:
get_filename_component(HARMONY_PROJECT_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../../../.." ABSOLUTE)
get_filename_component(PHOTOFLARE_ROOT "${HARMONY_PROJECT_ROOT}/.." ABSOLUTE)
Qt for Harmony SDK 通过 QT_PREFIX 指定:
set(QT_PREFIX "qtforharmony_sdk" CACHE PATH "Qt for HarmonyOS SDK path")
如果传入的是相对路径,就会按 harmony_pc 下的路径解析;如果是绝对路径,则直接使用本机 SDK。为了让 Photoflare 工程可以独立构建,这次把 Qt for Harmony SDK 放到了当前项目自己的 harmony_pc/qtforharmony_sdk/ 目录下:
"arguments": "-DQT_PREFIX=qtforharmony_sdk -DPHOTOFLARE_ENABLE_GRAPHICSMAGICK=AUTO"
也就是说,SDK 不应该长期指向 MoonPlayer 或其他参考项目里的绝对路径。可以参考别人的工程结构,但当前项目最终应该自带或显式配置自己的 Qt for Harmony SDK:
harmony_pc/qtforharmony_sdk/
如果换机器构建,只要保证这个目录存在,或者把 QT_PREFIX 改成当前机器上的 SDK 绝对路径即可。
这次链接的 Qt 模块包括:
find_package(Qt5 REQUIRED COMPONENTS Core Gui Widgets PrintSupport Network Svg)
Photoflare 的源码、UI 文件、工具、对话框、qrc 资源都被纳入 entry:
add_library(entry SHARED ${PHOTOFLARE_SOURCES} ${PHOTOFLARE_FORMS})
其中比较重要的是:
src/main.cpp
src/mainwindow.cpp
src/dialogs/NewDialog.cpp
src/dialogs/textdialog.cpp
src/widgets/PaintWidget.cpp
src/widgets/RulerWidget.cpp
src/widgets/brushtypecombobox.cpp
src/HarmonyUi.cpp
Icons.qrc
IconsDark.qrc
Photoflare 桌面版依赖 GraphicsMagick/Magick++ 处理部分滤镜和图片能力,但 HarmonyOS arm64 版本不一定随手可用。因此本次适配把 GraphicsMagick 做成可选项:
set(PHOTOFLARE_ENABLE_GRAPHICSMAGICK "AUTO" CACHE STRING "Enable GraphicsMagick on HarmonyOS: AUTO, ON, or OFF")
含义如下:
AUTO:如果找到 HarmonyOS arm64 GraphicsMagick,就启用;找不到则构建一个可启动、部分滤镜降级的版本。ON:强制要求 GraphicsMagick,找不到就构建失败。OFF:始终不启用 GraphicsMagick。
这一步的取舍是为了先把 Photoflare 主界面、画布、工具栏、基础绘制流程跑起来,再逐步补齐完整滤镜能力。
五、入口函数改造:从桌面 main 到鸿蒙 qtmain
普通桌面版 Photoflare 的启动路径就是创建 QApplication,再创建 MainWindow。但鸿蒙上 Qt 应用是被 QPA 插件从 Ability 生命周期里拉起的,argc/argv 可能为空,而且应用可能收到新的打开参数。
因此 src/main.cpp 中对 Q_OS_OPENHARMONY 做了单独处理:
#if defined(Q_OS_OPENHARMONY)
static int fallbackArgc = 1;
static char appName[] = "photoflare";
static char *fallbackArgv[] = { appName, nullptr };
static QApplication *app = nullptr;
static MainWindow *window = nullptr;
if (argc <= 0 || argv == nullptr) {
argc = fallbackArgc;
argv = fallbackArgv;
}
if (app != nullptr) {
if (window != nullptr) {
openLaunchArguments(*window, argc, argv);
window->show();
window->raise();
window->activateWindow();
}
return 0;
}
app = new QApplication(argc, argv);
QApplication::setDesktopFileName("photoflare");
HarmonyUi::applyApplicationStyle(app);
setupPhotoflareApplication(*app);
window = new MainWindow();
window->show();
openLaunchArguments(*window, argc, argv);
return app->exec();
#endif
同时导出 qtmain:
#if defined(Q_OS_OPENHARMONY)
extern "C" int qtmain(int argc, char *argv[])
{
return main(argc, argv);
}
#endif
第一次白屏排查时,这部分非常关键。如果 ArkTS 侧没有明确 launchApplication = 'libentry.so',native 侧又没有按 Qt for Harmony 的入口方式导出和启动,HAP 可以安装,窗口也可能出来,但实际 Qt 主窗口不会画出来。
这类白屏不要先猜 UI 问题,建议按顺序检查:
EntryAbility.ets里有没有指定libentry.so。Index.ets里XComponent的libraryname是否是 QPA 插件。- HAP 里是否收集到了
libplugins_platforms_qopenharmony.so。 - native 侧是否导出
qtmain。 src/main.cpp是否能在argc/argv为空时创建QApplication。
六、构建、安装和启动命令
本次鸿蒙工程路径是:
~/XM/photoflare-master/harmony_pc
命令行构建:
cd ~/XM/photoflare-master/harmony_pc
~/ohos/command-line-tools/bin/hvigorw --mode module -p module=entry assembleHap --no-daemon
签名后的 HAP 输出:
~/XM/photoflare-master/harmony_pc/entry/build/default/outputs/default/entry-default-signed.hap
安装到设备:
/Applications/DevEco-Studio.app/Contents/sdk/default/openharmony/toolchains/hdc install -r ~/XM/photoflare-master/harmony_pc/entry/build/default/outputs/default/entry-default-signed.hap
启动应用:
/Applications/DevEco-Studio.app/Contents/sdk/default/openharmony/toolchains/hdc shell aa force-stop io.photoflare.photoflare
/Applications/DevEco-Studio.app/Contents/sdk/default/openharmony/toolchains/hdc shell aa start -b io.photoflare.photoflare -a EntryAbility
抓取鸿蒙 PC 当前屏幕:
/Applications/DevEco-Studio.app/Contents/sdk/default/openharmony/toolchains/hdc shell snapshot_display -f /data/local/tmp/photoflare.jpeg
/Applications/DevEco-Studio.app/Contents/sdk/default/openharmony/toolchains/hdc file recv /data/local/tmp/photoflare.jpeg ./photoflare.jpeg

七、从白屏到主界面:先解决启动链路
第一次运行时遇到的是白屏:鸿蒙窗口起来了,但 Photoflare 主界面没有显示。
这类问题的核心判断是:白屏发生在 Qt 应用启动前,还是 Qt 应用启动后界面绘制失败?
本次排查后,主要修复点有两个。
第一,ArkTS 侧补上 native 应用入口:
private launchApplication = 'libentry.so';
第二,C++ 侧补上 OpenHarmony 启动分支和 qtmain 导出,保证 QPA 插件能进入真正的 Qt 应用入口:
extern "C" int qtmain(int argc, char *argv[])
{
return main(argc, argv);
}
同时在 HarmonyOS 下启用高 DPI 相关属性:
#if defined(Q_OS_OPENHARMONY) && QT_VERSION < QT_VERSION_CHECK(6, 0, 0)
QCoreApplication::setAttribute(Qt::AA_EnableHighDpiScaling);
QCoreApplication::setAttribute(Qt::AA_UseHighDpiPixmaps);
#endif
修复后,Photoflare 可以显示主窗口、菜单栏、工具栏、右侧工具面板和画布区域。至此,适配从“能不能启动”进入到“真实使用是否正常”的阶段。
八、弹窗挤压问题:固定坐标 UI 在鸿蒙高 DPI 下不稳
Photoflare 很多老式 Qt Widgets 弹窗来自 .ui 固定坐标布局。桌面上看起来没问题,但进入鸿蒙 PC 后,字体、DPI、窗口缩放和控件默认高度都发生变化,固定坐标就容易出问题。
最先暴露的是 NewDialog:控件有重叠,按钮和输入框之间的空间不够。修复思路不是继续调坐标,而是把它改成真正的 Qt layout:
void NewDialog::setupResponsiveLayout()
{
QGridLayout *pixelLayout = new QGridLayout(ui->groupBox);
...
QVBoxLayout *mainLayout = new QVBoxLayout(this);
mainLayout->addWidget(ui->groupBox);
mainLayout->addWidget(ui->groupBox_2);
mainLayout->addWidget(ui->buttonBox);
setLayout(mainLayout);
}
这样控件高度由 layout 管理,不再依赖 .ui 里写死的 x/y/width/height。
后续又给一些简单弹窗加了通用工具:
HarmonyUi::applyFixedDialogSizing(this);
它用于在 HarmonyOS 下对简单 fixed-size dialog 做一次保守放大,避免按钮被裁掉。
但这里也踩了一个真实坑:Text 弹窗比较复杂,里面有字体选择、颜色块、复选框、对齐按钮、输入框、预览区和按钮盒。直接用通用 fixed dialog 缩放,会导致内部子控件被挤压,字体框和预览区都不正常。
最终处理方式是:Text 弹窗不再走通用缩放,而是在 C++ 侧重排成真正的响应式布局:
void textDialog::setupResponsiveLayout()
{
QGridLayout *topLayout = new QGridLayout();
QGridLayout *styleLayout = new QGridLayout();
QHBoxLayout *contentLayout = new QHBoxLayout();
contentLayout->addLayout(inputLayout, 1);
contentLayout->addLayout(previewColumnLayout, 0);
QVBoxLayout *mainLayout = new QVBoxLayout(this);
mainLayout->addWidget(ui->tabWidget, 1);
mainLayout->addWidget(ui->buttonBoxtextDialog);
setLayout(mainLayout);
}
这一步之后,Text 弹窗的输入区、预览区、OK/Cancel 都能完整显示。
九、按钮颜色问题:Harmony 上默认状态不一定适合老 Qt UI
启动和弹窗布局修完后,又出现了更细的视觉问题:一些按钮在鸿蒙 PC 上变成接近纯白,和弹窗背景几乎一样。
典型现象包括:
New弹窗里的 OK 按钮白底白字,看起来像消失。- 工具面板里的选中按钮只是很浅的灰色,不容易判断当前工具。
Text弹窗和右侧工具设置里的 checkbox/radio 指示器偏白,在白底上不明显。- 一些按下态、选中态、禁用态没有足够对比度。
这不是单个按钮的问题,而是 Qt for Harmony 默认 palette 和 Photoflare 老式 UI 混合后产生的状态颜色问题。
因此新增了 HarmonyUi::applyApplicationStyle(),只在 Q_OS_OPENHARMONY 下生效:
void applyApplicationStyle(QApplication *app)
{
#if defined(Q_OS_OPENHARMONY)
QPalette palette = app->palette();
palette.setColor(QPalette::Window, QColor("#f7f8fa"));
palette.setColor(QPalette::WindowText, QColor("#20242a"));
palette.setColor(QPalette::Base, QColor("#ffffff"));
palette.setColor(QPalette::Text, QColor("#20242a"));
palette.setColor(QPalette::Button, QColor("#f3f5f8"));
palette.setColor(QPalette::ButtonText, QColor("#20242a"));
palette.setColor(QPalette::Disabled, QPalette::ButtonText, QColor("#7b828c"));
palette.setColor(QPalette::Highlight, QColor("#1a73e8"));
app->setPalette(palette);
app->setStyleSheet(QStringLiteral(
"QPushButton { color: #20242a; background: #f4f6f9; border: 1px solid #c7ced8; }"
"QPushButton:pressed { background: #d7e8ff; border-color: #4d8fe5; }"
"QPushButton:checked { background: #dcecff; border-color: #2f80ed; }"
"QPushButton:disabled { background: #edf0f4; border-color: #d1d6de; color: #7b828c; }"
"QToolButton:checked { background: #dcecff; border-color: #2f80ed; }"
"QCheckBox::indicator { border: 1px solid #98a2ad; background: #ffffff; }"
"QRadioButton::indicator { border: 1px solid #98a2ad; background: #ffffff; }"
));
#endif
}
主窗口工具面板还单独加强了选中态:
const QString lightToolButtonStyle =
"QToolButton { background: transparent; border: 1px solid transparent; border-radius: 5px; padding: 3px; }"
"QToolButton:hover { background: #eef5ff; border-color: #9dc4f5; }"
"QToolButton:pressed { background: #d7e8ff; border-color: #4d8fe5; }"
"QToolButton:checked { background: #dcecff; border-color: #2f80ed; }";
这样既不会把整个应用改成另一个重主题,又能保证关键状态可见。

十、颜色下拉框的隐藏问题:QPixmap 不能按 QImage 读取
在验证 New 弹窗时,还发现一个容易被忽略的问题:颜色下拉框里添加的是 QPixmap:
QPixmap pixmap(QSize(ui->backgroundColorComboBox->width(), ui->backgroundColorComboBox->height()));
pixmap.fill(static_cast<Qt::GlobalColor>(i));
ui->backgroundColorComboBox->addItem(QString(), pixmap);
但旧代码在取色时按 QImage 读取:
QImage img = ui->backgroundColorComboBox->currentData().value<QImage>();
return img.pixel(0, 0);
这在某些桌面环境下可能没有马上暴露,但在 Harmony Qt 运行时里就容易拿到空数据,进而影响新建画布背景色。
修复方式是在 BrushTypeComboBox 里加统一方法:
QColor BrushTypeComboBox::currentColor(const QColor &fallback) const
{
const QVariant data = itemData(currentIndex(), Qt::UserRole);
const QPixmap pixmap = data.value<QPixmap>();
if (!pixmap.isNull()) {
const QImage image = pixmap.toImage();
if (!image.isNull())
return image.pixelColor(0, 0);
}
const QImage image = data.value<QImage>();
if (!image.isNull())
return image.pixelColor(0, 0);
return fallback;
}
然后替换 NewDialog、GradientDialog、dropshadowDialog、OuterFrameDialog、batchDialog 中的取色逻辑:
return ui->backgroundColorComboBox_NewFile->currentColor(Qt::white);
修复后,点击 New 弹窗的 OK 按钮可以稳定创建白色 640x480 画布。



十一、文件打开:处理 file:// 和 content:// URI
作为图片编辑器,Photoflare 不能只从应用内部打开文件,还要能响应系统文件入口。鸿蒙传进来的路径可能是:
file://...
content://...
Qt 桌面代码更习惯拿普通本地路径。因此 EntryAbility.ets 中做了一层缓存:
private async cacheExternalUri(uri: string): Promise<string> {
if (uri === '' || (!uri.startsWith('file://') && !uri.startsWith('content://'))) {
return uri;
}
let destinationPath = this.cachedOpenPath(uri);
let destinationUri = fileUri.getUriFromPath(destinationPath);
if (uri.startsWith('file://')) {
let sourcePath = new fileUri.FileUri(uri).path;
await fs.copyFile(sourcePath, destinationPath, 0);
return destinationPath;
}
await fs.copy(uri, destinationUri);
return destinationPath;
}
这样外部传入的图片会先复制到应用缓存目录,再把缓存路径作为启动参数传给 Qt。这个做法符合鸿蒙沙箱模型,也避免 Qt 直接读取 content:// 失败。
module.json5 中也声明了图片打开能力:
{
"actions": [
"ohos.want.action.viewData"
],
"entities": [
"entity.system.default"
],
"uris": [
{
"scheme": "file",
"type": "image/*",
"linkFeature": "FileOpen"
},
{
"scheme": "content",
"type": "image/*",
"linkFeature": "FileOpen"
}
]
}
这部分不是 UI 修复,但对图片编辑器很重要。否则应用能从桌面图标启动,却不能自然接入系统文件流转。
十二、最终验证清单
这次适配最终验证了下面这些点:
- HAP 可以通过命令行构建并签名。
- 应用可以安装到鸿蒙 PC 设备。
io.photoflare.photoflare可以通过aa start启动。- 主窗口不再白屏,Qt Widgets 菜单栏、工具栏、工具面板正常显示。
New弹窗布局正常,OK/Cancel 文字可见。- 新建 640x480 画布成功。
- 右侧工具按钮选中态、点击态可见。
Text弹窗不再挤压裁切。- checkbox/radio 在白底上可见。
- 颜色下拉框取色稳定,不再把
QPixmap错当QImage。
十三、适配复盘
这次 Photoflare 的适配有一个很典型的节奏:
第一阶段是让 Qt 应用进入鸿蒙窗口。这个阶段主要看 Ability + XComponent + QPA + libentry.so + qtmain 是否连通。白屏问题大多出在这里。
第二阶段是让原有源码能被鸿蒙 CMake 工程组织起来。Photoflare 的 C++ 文件、UI 文件、qrc、Qt 模块、可选 GraphicsMagick 都要在 CMakeLists.txt 里重新梳理。
第三阶段是真机 UI 问题。桌面 Qt Widgets 项目迁到鸿蒙 PC 后,最容易暴露的是 fixed geometry、DPI、字体高度、按钮状态颜色和控件 palette 的问题。这里不能只以“能启动”为目标,还要把常用弹窗逐个点开看。
第四阶段是数据细节。比如颜色下拉框存的是 QPixmap,旧代码按 QImage 取值,这种问题平时不一定明显,但换运行时后就会变成真实 bug。
最终的经验是:Qt Widgets 项目适配鸿蒙 PC 时,ArkTS 侧尽量薄,Qt 侧尽量保留原逻辑;鸿蒙差异集中放在 Q_OS_OPENHARMONY、harmony_pc/ 和少量平台辅助文件里。这样既能跑通 HAP,又不会把原桌面项目改成另一套难维护的代码。
更多推荐



所有评论(0)