手搓跨平台 UI 引擎
现在市面上带 UI 的各种跨平台框架层出不穷,如 QT、Flutter、RN、Skia 等。他们很强大,但可能并不一定满足某些场景的需求(如性能+轻量+可定制)。
那如果要自己实现一个跨平台的 UI 引擎,该如何入手?稍微熟悉各端原生 UI 框架底层细节的人,可能很多会望而却步,因为里面涉及太多东西:
- 跟原生渲染引擎(OpenGL/Metal/Vulkan)的交互;
- 处理系统事件(如鼠标、键盘、触摸),事件分发;
- 图片的加载、渲染、缓存,文本的渲染、排版;
- 各种组件的封装,各种布局的实现,动画系统等;
- 高效的 UI 刷新机制,支持响应式和局部更新;
- UI 描述语言,支持动态/静态方式创建;
- 各种内存和性能优化机制…
看起来比较唬人,但当你真正一砖一瓦地逐步搭建起来,可能最后会发现其实也没那么复杂。
当然,这里仅仅是搭建基础的 UI 框架(支持图文、布局、事件、动画等,满足一般简单场景),要真正做得很通用和完善确实还需要花很多精力(起码的敬畏还是要有)。
但至少能从中加深对 UI 框架底层原理的理解,而不仅限于死记硬背那些八股文。
图形后端选择
首先说结论,目前真正能全平台提供完善支持的图形后端,有且仅有 OpenGL。
Metal/DirectX 仅支持自家平台,直接 pass;
Vulkan 虽然目前在 Apple 以外的平台支持越来越广泛,在 Apple 上也可以通过 MoltenVK 提供支持,但也存在不少问题:
- Android 10 才支持 Vulkan 1.1 (某些硬件虽声称支持,但存在驱动 bug 或性能问题);
- 学习门槛高,市面上的资料也不太多;
而反观 OpenGL 虽看起来年迈,但优势依然存在:
- 跨平台支持完善,学习资料及第三方库丰富(AI 的支持自然也更完善);
- Apple 虽将其标记为 deprecated,但仍支持自动转译,可享受 Metal 的性能优势;
- Android 15 通过引入 ANGLE 提供了转译支持,同样可享受 Vulkan 的性能优势。
从这两家都支持转译来看,短期内 OpenGL 仍难以替代。
OpenGL 渲染环境
移动端
对于 Android/iOS 的渲染环境,前文有详细介绍,这里不再赘述。
而鸿蒙端,笔者暂未实践过,后续再补充这部分。
PC 端
PC 端,可以借助开源的 glfw 来管理 OpenGL 渲染窗口。
加载器
首先介绍下 glad,它是一个 OpenGL 加载器,用于在运行时加载 OpenGL 环境,甚至还支持 Vulkan。
它其实主要是抹平了不同平台之间 OpenGL 函数的差异(做了映射),使得在不同平台上使用 OpenGL 时,代码可以保持一致。
另外还推荐一个 glad loader generator:gen.glad.sh,可以根据当前平台的 OpenGL 版本,自动生成对应的加载器代码。
窗口创建
glfwInit();
if (!glfwInit()) {
std::cerr << "Failed to initialize GLFW" << std::endl;
return;
}
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
const auto window = glfwCreateWindow(800, 600, "UI Engine", nullptr, nullptr);
if (!window) {
std::cerr << "Failed to create GLFW window" << std::endl;
glfwTerminate();
return;
}
if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress)) {
std::cerr << "Failed to init GLAD" << std::endl;
}
glfwSetWindowUserPointer(window, this);//传入用户指针,方便后续各种回调
窗口可见性控制
glfwWindowHint(GLFW_VISIBLE, GLFW_FALSE);//初始化时隐藏
glfwIconifyWindow(window);//最小化
glfwRestoreWindow(window);//最大化
glfwHideWindow(window);//完全隐藏(无法通过状态栏恢复)
glfwShowWindow(hiddenWin);//显示
glfwFocusWindow(hiddenWin);//聚焦
int visible = glfwGetWindowAttrib(window, GLFW_VISIBLE);//查询窗口是否可见
int iconified = glfwGetWindowAttrib(window, GLFW_ICONIFIED);//查询窗口是否最小化
int isFocused = glfwGetWindowAttrib(window, GLFW_FOCUSED);//查询窗口是否聚焦
glfwSetWindowVisibilityCallback(window, onVisibilityChanged);//监听窗口可见性变化
glfwSetWindowIconifyCallback(window, onIconifyChanged);//监听窗口最小化变化
glfwSetWindowFocusCallback(window, onFocusChanged);//监听窗口聚焦变化
窗口渲染
//监听窗口大小变更
void onFrameBufferSizeChanged(GLFWwindow* window, int w, int h) {
width = w;
height = h;
glViewport(0, 0, w, h);
}
glfwSetFramebufferSizeCallback(window, [](GLFWwindow* w, int width, int height) {
const auto obj = static_cast<GLWin*>(glfwGetWindowUserPointer(w));
obj->onFrameBufferSizeChanged(w, width, height);
});
glViewport(0, 0, width, height);//视口变换
while (!glfwWindowShouldClose(window)) {
if (glfwGetWindowAttrib(window, GLFW_VISIBLE) == GLFW_FALSE) {
continue;//窗口不可见不用渲染
}
glfwMakeContextCurrent(window);//切换渲染上下文
//真正的 OpenGL 渲染逻辑
glfwSwapBuffers(window);//交换缓冲区,将渲染结果显示到窗口
}
窗口销毁
glfwSetWindowCloseCallback(window, [](GLFWwindow* w) {
const auto obj = static_cast<GLWin*>(glfwGetWindowUserPointer(w));
obj->onWindowClose(w);
});
glfwSetWindowShouldClose(window, GLFW_TRUE);//设置窗口关闭标志,触发窗口关闭回调
glfwDestroyWindow(window);
glfwTerminate();
原生 UI 事件拦截
移动端
对于触摸事件,移动端需要通过原生组件拦截 Touch 事件坐标以及类型(跨平台场景不需要像原生一样区分那么细,只需要区分是否是 Down);
对于键盘输入事件,同样是需要原生输入框控件去负责监听输入内容、触发键盘显示与隐藏。
具体的这里就不展开了,也没啥高深的内容。
PC 端
PC 端仍然是通过 glfw 来监听鼠标和键盘事件:
auto status = glfwGetMouseButton(window, GLFW_MOUSE_BUTTON_LEFT) == GLFW_PRESS;//判断左键是否按下
double x, y;
glfwGetCursorPos(window, &x, &y);//获取鼠标当前坐标
glfwSetCharCallback(window, onCharChanged);//监听字符输入事件
glfwSetKeyCallback(window, onKeyChanged);//监听键盘输入事件
glfwSetInputMode(window, GLFW_CURSOR, GLFW_CURSOR_DISABLED);//禁用系统鼠标
glfwSetInputMode(window, GLFW_STICKY_KEYS, GLFW_TRUE);//启用键盘重复事件
对于 Windows 还有些特殊处理,比如需要禁用系统的鼠标和键盘事件,否则会与 glfw 冲突:
#if defined(_WIN32)
#ifndef GLFW_EXPOSE_NATIVE_WIN32
#define GLFW_EXPOSE_NATIVE_WIN32
#endif
static WNDPROC originalWndProc;
LRESULT CALLBACK handleWindowsImeWindow(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam) {
switch (uMsg) {
case WM_IME_STARTCOMPOSITION:
usingIMM = true;
break;
case WM_IME_ENDCOMPOSITION:
usingIMM = false;
break;
case WM_IME_COMPOSITION: {
HIMC hIMC = ImmGetContext(hwnd);
if (lParam & GCS_RESULTSTR) {
std::array<wchar_t, 256> buffer;
if (const auto size = ImmGetCompositionString(hIMC, GCS_RESULTSTR, buffer.data(), buffer.size()); size > 0) {
onImeInput(buffer.data());
}
}
ImmReleaseContext(hwnd, hIMC);
break;
}
default:
return CallWindowProc(originalWndProc, hwnd, uMsg, wParam, lParam);
}
return 0;
}
void windowsSetIMEPosition(int x, int y) {
auto hwnd = glfwGetWin32Window(window);
if (const auto hIMC = ImmGetContext(hwnd); hIMC) {
COMPOSITIONFORM cf;
cf.dwStyle = CFS_POINT;
cf.ptCurrentPos.x = x;
cf.ptCurrentPos.y = y;
ImmSetCompositionWindow(hIMC, &cf);
ImmReleaseContext(hwnd, hIMC);
}
}
void windowsSetCustomWndProc(GLFWwindow* window) {
HWND hwnd = glfwGetWin32Window(window);
originalWndProc = (WNDPROC)GetWindowLongPtr(hwnd, GWLP_WNDPROC);
SetWindowLongPtr(hwnd, GWLP_WNDPROC, (LONG_PTR)handleWindowsImeWindow);
}
#endif