手搓跨平台 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

Shader 管理

图片加载及渲染

文本渲染

容器组件实现

布局与动画

UI 事件分发

UI 描述及编辑器

性能优化