Aura UI 框架 macOS 移植:跨平台 UI 的工程实践

最近把一个 Windows 平台土生土长的 UI 框架 Aura UI 跑到了 macOS 上。整个过程比预想的要折腾,但也积累了不少跨平台 UI 移植的经验,记录一下。

1. 项目背景:Aura UI 是什么

Aura UI 是一个轻量级的跨平台 UI 框架,最早服务于 PC 浏览器产品线。核心设计思路是:平台无关的业务逻辑 + 平台相关的渲染层,通过抽象层隔离差异。

框架结构大致如下:

1
2
3
4
5
aura/           # 平台无关层:View, Widget, Button, Label...
gfx/ # 2D 图形:Canvas, Color, Font, Bitmap...
animation/ # 动画引擎:Tween, LinearAnimation...
message_framework/ # 消息循环
base/ # 基础库:原子操作、线程检查、类型定义...

原有实现完全基于 Windows GDI/GDI+,所有渲染都通过 Canvas 类封装 HDC 完成。

2. macOS 移植:架构设计

macOS 移植的核心策略是 “#ifdef 隔离 + ObjC++ 桥接”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// aura_ui_mac.h
#ifdef AURA_MAC

#if defined(__APPLE__)
#define AURA_PLATFORM_MAC 1
#elif defined(_WIN32)
#define AURA_PLATFORM_WIN 1
#endif

#if AURA_PLATFORM_MAC
#import <Cocoa/Cocoa.h>
#import <AppKit/AppKit.h>
#endif

// 跨平台类型桥接
typedef CGContextRef PlatformDC; // macOS
typedef HDC PlatformDC; // Windows

#endif // AURA_MAC

这样 Windows 代码完全不受影响,macOS 代码只在定义了 AURA_MAC 时才参与编译。

3. 踩坑实录

坑一:.cpp → .mm —— Objective-C++ 的编译单元问题

Cocoa 的 API 都是 ObjC 对象(NSButton*NSTextField*),C++ 代码无法直接调用。解决方案:把包含 ObjC 代码的文件从 .cpp 改成 .mm(GNUstep 默认支持 ObjC++ 混编)。

1
2
3
4
5
6
7
8
9
10
11
12
// controls_mac.mm (注意后缀!)
#import <Cocoa/Cocoa.h>
#include "aura/controls.h"

namespace aura {

void Button::CreateButton() {
button_ = [[NSButton alloc] init];
[[button_ cell] setControlSize:NSControlSizeRegular];
}

} // namespace aura

教训:如果文件名还是 .cpp,编译器会拒绝 ObjC 语法,哪怕你 import <Cocoa/Cocoa.h>

坑二:NSScrollBar ≠ NSSlider

在 macOS 上做滚动条替换时,最开始用了 NSSlider 代替 Windows 的滚动条控件,结果样式完全不对。后来发现 macOS 的 NSScrollBar 才是对的——但这个坑来自于 Windows API 的思维惯性。

坑三:wstring → NSString 的字符编码

Windows 用 wstring(UTF-16 LE),macOS 用 NSString(UTF-16 或 UTF-8)。Canvas 文字渲染时做了大量这个转换:

1
2
3
4
5
6
#if AURA_PLATFORM_MAC
NSString* StringToNSString(const std::wstring& ws) {
return [NSString stringWithCharacters:(const unichar*)ws.data()
length:ws.length()];
}
#endif

注意这里 wstring.data() 在 macOS(Apple Clang)下返回的是 const unichar*,可以直接传给 NSString

坑四:include 路径污染 —— time.h 被”劫持”

CMake 配置里加了 -I base,导致系统的 time.h 被项目里同名文件遮掉了,编译报一堆奇怪的宏定义冲突。

解决:CMakeLists.txt 里删掉 -I base,让基础类型的 include 只走系统路径。

坑五:ObjC 头文件的编译条件

有些 .h 文件被 C++ 和 ObjC 同时包含,需要加 __OBJC__ 宏保护:

1
2
3
4
// color.h
#ifdef __OBJC__
#import <AppKit/AppKit.h> // NSColor* only available in ObjC
#endif

没有这个 guard,C++ 编译器会报 unknown type name 'NSColor'

坑六:atomicops —— Windows → POSIX 切换

原来原子操作用的是 Windows Intrinsics(_InterlockedIncrement 等),macOS 上得换成 GCD 或 POSIX 原子函数。这个改起来不算难,但需要 platform macro 判断。

4. 跨平台 Canvas 设计

Canvas 是整个图形系统的核心抽象,跨平台设计如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// gfx/canvas.h
#if PLATFORM_WINDOWS
typedef HDC PlatformDC;
#elif PLATFORM_MACOS
typedef CGContextRef PlatformDC;
#endif

class Canvas {
public:
static Canvas* CreateCanvas(int width, int height, bool is_opaque);

// 跨平台文字渲染
void DrawStringInt(const std::wstring& text, const gfx::Rect& rect,
int flags); // TEXT_ALIGN_LEFT|CENTER|RIGHT 等

private:
PlatformDC dc_; // 平台相关 DC
};

Windows 上 dc_HDC,macOS 上是 CGContextRef。渲染实现按平台分发:

1
2
3
4
5
6
7
8
9
#if PLATFORM_WINDOWS
void Canvas::DrawStringInt(const std::wstring& text, const gfx::Rect& rect, int flags) {
DrawStringWin(text, rect, flags); // GDI 路径
}
#elif PLATFORM_MACOS
void Canvas::DrawStringInt(const std::wstring& text, const gfx::Rect& rect, int flags) {
DrawStringMac(text, rect, flags); // CoreText 路径
}
#endif

5. 移植完成度

目前已完成的移植模块:

模块 状态 说明
aura_view / aura_widget 窗口、视图基础
Button / Label / TextField 基础控件
Checkbox / ComboBox 进阶控件
gfx::Canvas CoreGraphics 渲染
animation Tween 动画
message_framework 任务队列

macOS 上可以编译出 libchromium_ui.a 静态库并成功链接。

6. 经验总结

跨平台 UI 移植的几个原则:

  1. 平台宏前置:在所有 include 之前就要判断平台,绝不能等业务代码写完再加
  2. .mm 后缀要早定:一旦文件里出现 ObjC 代码,立刻改名,别等编译器报错
  3. Include 路径要干净:项目 include 目录越少越好,优先用完整路径
  4. 类型桥接层要薄:PlatformDC、StringBridge 这种桥接层薄薄一层就够了,别把平台差异散得到处都是
  5. OBJC guard 要养成习惯:所有 ObjC 相关的 #import 都要加 __OBJC__ guard

项目源码:github.com/shangsh/chromium_ui

有问题欢迎留言交流。