一、写在前面

欢迎加入鸿蒙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,而是解决下面这些问题:

  1. 怎样让一个 Qt Widgets 桌面程序进入鸿蒙 Stage 模型。
  2. 怎样让 libentry.so 作为 HAP native library 被启动。
  3. 怎样把 Qt Widgets 主窗口绘制到 ArkUI XComponent 上。
  4. 怎样把 Qt for Harmony 的 QPA 插件、Qt 动态库、UI 文件、qrc 资源和原项目 C++ 源码一起打包。
  5. 怎样处理 GraphicsMagick 这类桌面依赖在鸿蒙 arm64 环境下暂时不完整的问题。
  6. 怎样解决第一次运行白屏、弹窗挤压、按钮白底白字、工具栏选中态不明显这些真实设备问题。
  7. 怎样处理鸿蒙文件 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);

这几个点很关键:

  1. launchApplication 指定真正要启动的 native Qt 应用库,也就是 libentry.so
  2. windowStage.loadContent() 先加载包含 XComponent 的 ArkUI 页面。
  3. qpa.handleJsTopWindowCreated() 把鸿蒙顶层窗口交给 Qt QPA 插件。
  4. 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")

含义如下:

  1. AUTO:如果找到 HarmonyOS arm64 GraphicsMagick,就启用;找不到则构建一个可启动、部分滤镜降级的版本。
  2. ON:强制要求 GraphicsMagick,找不到就构建失败。
  3. 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 问题,建议按顺序检查:

  1. EntryAbility.ets 里有没有指定 libentry.so
  2. Index.etsXComponentlibraryname 是否是 QPA 插件。
  3. HAP 里是否收集到了 libplugins_platforms_qopenharmony.so
  4. native 侧是否导出 qtmain
  5. 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 上变成接近纯白,和弹窗背景几乎一样。

典型现象包括:

  1. New 弹窗里的 OK 按钮白底白字,看起来像消失。
  2. 工具面板里的选中按钮只是很浅的灰色,不容易判断当前工具。
  3. Text 弹窗和右侧工具设置里的 checkbox/radio 指示器偏白,在白底上不明显。
  4. 一些按下态、选中态、禁用态没有足够对比度。

这不是单个按钮的问题,而是 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;
}

然后替换 NewDialogGradientDialogdropshadowDialogOuterFrameDialogbatchDialog 中的取色逻辑:

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 修复,但对图片编辑器很重要。否则应用能从桌面图标启动,却不能自然接入系统文件流转。


十二、最终验证清单

这次适配最终验证了下面这些点:

  1. HAP 可以通过命令行构建并签名。
  2. 应用可以安装到鸿蒙 PC 设备。
  3. io.photoflare.photoflare 可以通过 aa start 启动。
  4. 主窗口不再白屏,Qt Widgets 菜单栏、工具栏、工具面板正常显示。
  5. New 弹窗布局正常,OK/Cancel 文字可见。
  6. 新建 640x480 画布成功。
  7. 右侧工具按钮选中态、点击态可见。
  8. Text 弹窗不再挤压裁切。
  9. checkbox/radio 在白底上可见。
  10. 颜色下拉框取色稳定,不再把 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_OPENHARMONYharmony_pc/ 和少量平台辅助文件里。这样既能跑通 HAP,又不会把原桌面项目改成另一套难维护的代码。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐