Shaft: 为极致性能与开发体验而生的跨平台UI框架

5,380 阅读10分钟

(一) Shaft: 为极致性能与开发体验而生的跨平台UI框架 (本文)

(二) GPU加速2D图形渲染原理 - 图形API、纹理与GPU

背景

几年前我在 Github 上开源了一个叫做 xterm.dart 的项目。xterm.dart 是一个跨平台的 Flutter 终端组件库。通过这个库,开发者可以在 Flutter 应用中创建可交互的终端模拟器。经过一系列性能优化后,xterm.dart 的性能可以不输系统原生终端:

xterm.gif

但是想要在当前基础上进一步提升性能,就会遇到一些难以突破的瓶颈:

  1. Dart、JS 等语言没有真正意义上的线程,无法将“解析终端数据”与“UI绘制”这两个相对独立的步骤放到两个线程中并行执行;
  2. 同时这些语言的分代式 GC 也会导致内存占用量高于实际需要。想必大家都体验过 Electron
  3. 终端里的每个字符位置固定,字符绘制过程中可以通过跳过排版这一步骤,来获得极大的性能提升,但是现有的 UI 框架往往不支持这种做法,本地修改/编译这些框架也较为繁琐

以上种种问题不免让人想到:

有没有一个跨平台UI框架

  • 有像 Flutter 和 SwiftUI 一样简单易用的声明式语法
  • 渲染性能媲美甚至超越系统原生 UI 库,并且内存占用更低
  • 在必要的时候可以任意访问底层图形 API/系统 API,并且无需手动维护 Binding
  • 框架能够根据需求自由定制,可以随意 fork 与魔改,而不需要繁琐的编译和发布流程

这些既要又要的结果就是 Shaft 项目的诞生:

github.com/ShaftUI/Sha…

架构

architecture.png

Shaft 由 FrameworkBackendRenderer 以及 ShaftKit四部分组成,其中

  • Framework 绝大部分是移植到 Swift 的 Flutter 代码,关于这部分可以直接参考 Flutter 官方文档
  • BackendRenderer 分别是对平台以及渲染引擎的抽象。目前默认的 Backend 使用 SDL3 实现,默认的 Renderer 使用 Skia 实现。更多实现版本正在准备中
  • ShaftKit 是 Shaft 内置的 UI 组件库,ButtonTextField 等基础组件都包含在 ShaftKit 内。ShaftKit 内的组件语义与样式分离,方便开发者以最小的成本开发自定义组件库。

先睹为快

demo.gif

图中运行的应用是Shaft内置的 Playground 示例,这个示例同时也是当前Shaft的官方文档。

3D Cube demo 中,Shaft能够直接将 Metal 纹理渲染到 UI 中,旗舰无需经过 gpu-cpu-gpu 拷贝!

特性

声明式 UI

Shaft 框架使用 Swift 语言编写,使用的示例如下:

import Shaft

runApp(
    MyApp()
)

class MyApp: StatelessWidget {
    func build(context: BuildContext) -> Widget {
        Center {
            Text("Hello, Shaft!")
        }
    }
}

等等,这不就是 Swift 版的 Flutter?

没错!Shaft 的框架部分就是直接将 Flutter 的 Dart 代码移植到了 Swift

文章末尾有 Shaft 移植代码的文件列表

在多数情况下你可以像使用 Flutter 一样使用 Shaft,并且 Shaft 仅对这些移植代码进行了少量修改,Flutter 中的绝大部分概念在 Shaft中仍然适用(例如 WidgetElementRenderObject )。对于没接触过 Flutter 的读者,这里推荐直接阅读 Flutter 官方介绍,或中文的 Flutter 实战

与 Flutter 相同,Shaft 也采用自绘制的方式渲染 UI,不依赖系统提供的 UI 组件,可以实现不同平台下完全一致的渲染效果和操作逻辑。

全自动 UI 与数据绑定

UI 本质上是用户与应用程序交互的媒介,用户通过 UI 获取当前应用的数据与运行状态。当应用的数据/状态出现变化时,我们希望 UI 及时更新,来让用户获得实时的反馈。

与多数框架不同,得益于 Swift 的 Observation framework,Shaft 可以在数据变更时自动更新 UI。

下边是一个简单的示例:

import Observation
import Shaft

@Observable
class Counter {
    var count = 0
}

let counter = Counter()

final class CounterView: StatelessWidget {
    func build(context: BuildContext) -> Widget {
        Column {
            Text("Count: \(counter.count)")

            Button {
                counter.count += 1
            } child: {
                Text("Increment")
            }
        }
    }
}

runApp(
    CounterView()
)

counter.gif

在这段示例代码中,CounterView 仅仅在构建UI的过程中读取了 counter.count ,并没有进行任何显式的数据监听,或者手动 UI 刷新。所以 Shaft 是如何实现在 counter.count 变化的时候自动触发UI更新?

答案就在 @Observable

在这段代码中我们对 Counter 类应用了 @Observable 这个macro,使得 Counter 类实例的数据变化可以被外部感知(详见 withObservationTracking)。Shaft 在 build() 期间会自动追踪所有被读取过的 @Observable 数据,并建立依赖关系,在数据有变更时自动触发一次 UI 的 rebuild。

在上边这段代码中,CounterViewbuild() 期间读取了 count,使得 CounterViewcount 产生了依赖。于是在按钮被按下时 count += 1 就自动触发了UI更新。

假设下次 build() 时因为某些 UI 逻辑,CounterView 没有读取 count,则这种依赖关系会自动解除,此时count 的变化不会再触发 UI 更新。

这种自动依赖建立与 UI 刷新在复杂场景也同样适用,例如:

@Observable
class ShoppingList {
    @Observable
    class Entry {
        init(name: String, quantity: Int) {
            self.name = name
            self.quantity = quantity
        }

        var name: String
        var quantity: Int
    }

    var items = [
        Entry(name: "Milk", quantity: 1),
        Entry(name: "Eggs", quantity: 12),
        Entry(name: "Bread", quantity: 2),
    ]

    var total: Int {
        items.reduce(0) { $0 + $1.quantity }
    }
}

demo_complex.gif

完整代码见 此处

@Observable 真正强大之处在于对 ArrayDictionary 以及多层嵌套数据都同样适用。使用这种方式,再也不会遇到“更新列表内数据后 UI 不刷新”这些反直觉的问题,也不需要写 store.items = [...items] 这种奇怪的语句了。

这开发体验超级棒的好吧!

完全开放底层 API

使用跨平台 UI 框架并不意味着不需要为特定平台编写代码,每个操作系统/平台都有各自独一无二的能力/特性,充分利用这些特性往往能够带来更好的用户体验。

在这方面,Shaft 利用了 Swift 可以静态链接并直接访问 C/C++ 代码的特性,在保证跨平台的同时让开发者可以直接访问各种底层接口:

class App: StatelessWidget {
    func build(context: BuildContext) -> Widget {
        Button {
            self.hideTitlebar(view: View.maybeOf(context))
        } child: {
            Text("Hide titlebar")
        }
    }

    func hideTitlebar(view: View) {
        /// Accessing the raw window object by downcasting the view.
        if let view = view as? MacOSView {
            let window = view.nsWindow
            window?.styleMask.insert(.fullSizeContentView)
            window?.titlebarAppearsTransparent = true
        }
    }
}

hide_title.gif

在上边这段代码中,当按钮被按下时我们对当前 View 进行类型转换。在 MacOS 平台下,我们可以将类型转换到 MacOSView ,此时就可以通过转换得到的 MacOSView 直接拿到原始的 NSWindow,通过 NSWindow 对窗口进行任意的操作,例如设置标题样式、增加窗口毛玻璃效果等。

这里为了方便使用 MacOS 演示,这种调用方法在其他平台同样适用!

下边这段代码展示了如何通过类型转换,获取当前窗口渲染所使用的原始图形API,直接将GPU纹理渲染到Shaft内:

如果你对图形 API,纹理这些概念感到陌生,可以先阅读这篇博客

import Metal

class MetalTextureView: StatefulWidget {
    func createState() -> MetalTextureViewState {
        MetalTextureViewState()
    }
}

class MetalTextureViewState: State<MetalTextureView> {
    func render() -> NativeImage? {
        guard let renderer = backend.renderer as? MetalRenderer else {
            return nil
        }
        let textureDescriptor = MTLTextureDescriptor()
        let texture = renderer.device.makeTexture(descriptor: textureDescriptor)!
        // Draw something to the texture
        return renderer.createMetalImage(texture: texture)

    }

    override func build(context: any BuildContext) -> any Widget {
        RawImage(image: render())
    }
}

在上边这段代码中发生了两次类型变化:

  1. 第一次我们通过将 Renderer 转换为 MetalRenderer ,通过 MetalRenderer 获取 MTLDevice 来创建Metal纹理。
  2. 第二次发生在 render() 函数返回时,我们通过 MetalRenderer.createMetalImage 将Metal纹理包装成 Shaft 可以渲染的 NativeImage 对象。

这里为了方便使用 Metal 演示,这种调用方法对于其他图形 API(Vulkan,OpenGL...) 同样适用!

自由定制

多数跨平台框架都至少由使用 C/C++ 等语言编写的引擎层,以及 JS 等语言编写的框架层组成。

以 Flutter 为例, Flutter 由以下几部分组成:

  • Flutter Framework: 以 Dart 语言编写,负责 UI 绘制、布局、事件处理等
  • Engine: C/C++ 实现的引擎层,负责 GPU 渲染等工作
  • Embedder: 以各平台原生语言编写,负责处理平台相关的逻辑。

由于这种分层结构,开发者访问平台相关的功能,就需要通过各种 message channel,或者 FFI 调用。这种调用方式不仅会显著增加代码量和维护成本,其中发生的跨语言数据拷贝也极易成为性能瓶颈。

同时不同层级之间复杂的交互也会显著增加修改框架代码的工作量,以及出现问题时 debug 的难度。

在大型项目中使用任何版本的 Flutter 都有大概率会遇到引擎相关问题,调试与修改引擎几乎是常态。但是因为极高的维护成本,很少有团队有能力单独维护一份 Flutter Engine 的 fork,使得相关问题的解决需要很长的时间周期。

但 Shaft 仅仅是一个单独的 Swift package,你可以直接修改源码,然后使用和分发,不需要单独编译、设置自定义引擎等。

同时 Swift 可以直接链接 C/C++ 代码的特性也使得 Shaft 可以在框架内直接调用各种底层 API,无需额外编写 ffi 代码:

import WinSDK

class App: StatelessWidget {
    func build(context: BuildContext) -> Widget {
        Button {
            self.showAlert()
        } child: {
            Text("Show alert")
        }
        .center()
    }

    func showAlert() {
        "Hello".withCString(encodedAs: UTF16.self) { message in
            MessageBoxW(
                nil,
                message,
                message,
                UINT(MB_OK)
            )
        }
    }
}

alert.gif

快速上手

Shaft 本身就是一个常规的 Swift package。确保系统中装有 Swift 以及一些依赖即可使用:

  • MacOS: 在App Store中安装 Xcode 即可。
  • Linux:
    1. 根据官方教程安装 Swift
    2. 安装 SDL 所需依赖。以Ubuntu24.04为例,需要执行:
    sudo apt install ninja-build pkg-config libasound2-dev libpulse-dev libaudio-dev libjack-dev libsndio-dev libusb-1.0-0-dev libx11-dev libxext-dev libxrandr-dev libxcursor-dev libxfixes-dev libxi-dev libxss-dev libwayland-dev libxkbcommon-dev libdrm-dev libgbm-dev libgl1-mesa-dev libgles2-mesa-dev libegl1-mesa-dev libdbus-1-dev libibus-1.0-dev libudev-dev fcitx-libs-dev libunwind-dev libpipewire-0.3-dev libdecor-0-dev libfontconfig-dev
    
  • Windows:根据官方教程安装 Swift 即可。注意当前Windows稳定版存在bug,选择版本时务必选择Development Snapshots

确保依赖安装完成后,即可以通过克隆 CounterTemplate 模板快速创建并运行一个 Shaft 项目:

git clone https://github.com/ShaftUI/CounterTemplate.git

cd CounterTemplate

swift package plugin setup-skia

swift run

counter.gif

更多 Shaft 相关文档见仓库 READMEPlayground 示例项目。接下来我也会继续发布文章来详细介绍 Shaft 的各种特性与使用方法。

结语

仓库地址: github.com/ShaftUI/Sha…

框架文件概览

Shaft 目前已经移植了相当一部分的 Flutter 框架代码:

.                                                       │   ├── Binding                                         ├── Scheduler
├── Animation                                           │   │   └── GestureBinding.swift                        │   ├── SchedulerBinding.swift
│   ├── Animatable.swift                                │   ├── DragDetails.swift                               │   └── Ticker.swift
│   ├── Animation.swift                                 │   ├── GestureConstants.swift                          ├── Services
│   ├── AnimationBase.swift                             │   ├── GestureDebug.swift                              │   ├── HardwareKeyboard.swift
│   ├── AnimationController.swift                       │   ├── GestureSettings.swift                           │   ├── MouseCursor.swift
│   ├── Curve.swift                                     │   ├── HitTest.swift                                   │   ├── TextBoundary.swift
│   └── Tween.swift                                     │   ├── LsqSolver.swift                                 │   ├── TextInput.swift
├── Backend                                             │   ├── PointerEventConverter.swift                     │   └── TextLayoutMetrics.swift
│   ├── RemoteAppBackend.swift                          │   ├── PointerEvents.swift                             ├── ShaftKit
│   ├── SDLBackend.swift                                │   ├── PointerRouter.swift                             │   ├── Background.swift
│   ├── SDLCursor.swift                                 │   ├── PointerSignalResolver.swift                     │   ├── Button.swift
│   ├── SDLKey.swift                                    │   ├── Recongnizer                                     │   ├── ControlSize.swift
│   ├── SDLMetalView.swift                              │   │   ├── LongPressRecongnizer.swift                  │   ├── Divider.swift
│   ├── SDLOpenGLView.swift                             │   │   ├── MonoDrag.swift                              │   ├── FixedListView.swift
│   ├── SDLView.swift                                   │   │   ├── Recongnizer.swift                           │   ├── ListTile.swift
│   ├── Skia                                            │   │   ├── TapAndDragRecongizer.swift                  │   ├── NavigationSplitView.swift
│   │   ├── SkiaCanvas.swift                            │   │   └── TapRecongnizer.swift                        │   ├── Resizable.swift
│   │   ├── SkiaImage.swift                             │   └── VelocityTracker.swift                           │   ├── Section.swift
│   │   ├── SkiaLoadICU.swift                           ├── Painting                                            │   ├── TextField.swift
│   │   ├── SkiaParagraph.swift                         │   ├── Alignment.swift                                 │   └── Typography.swift
│   │   ├── SkiaPath.swift                              │   ├── BasicTypes.swift                                └── Widgets
│   │   ├── SkiaRenderer+GL.swift                       │   ├── BorderRadius.swift                                  ├── Actions.swift
│   │   ├── SkiaRenderer+Metal.swift                    │   ├── Borders.swift                                       ├── Basic.swift
│   │   ├── SkiaRenderer.swift                          │   ├── BoxBorder.swift                                     ├── Binding
│   │   └── ToSkia.swift                                │   ├── BoxDecoration.swift                                 │   └── WidgetsBinding.swift
│   └── UIActor.swift                                   │   ├── BoxFit.swift                                        ├── Builder.swift
├── Core                                                │   ├── BoxShadow.swift                                     ├── Container.swift
│   ├── Backend.swift                                   │   ├── ClipContext.swift                                   ├── DefaultTextEditingShortcuts.swift
│   ├── Canvas.swift                                    │   ├── DecoratedImage.swift                                ├── Focus
│   ├── DisplayList                                     │   ├── Decoration.swift                                    │   ├── Focus.swift
│   │   ├── DisplayList.swift                           │   ├── EdgeInsets.swift                                    │   ├── FocusManager.swift
│   │   ├── DisplayListBuilder.swift                    │   ├── ImageProvider.swift                                 │   └── FocusTraversal.swift
│   │   └── DlOpReceiver.swift                          │   ├── Span                                                ├── Framework
│   ├── Geometry.swift                                  │   │   ├── InlineSpan.swift                                │   └── Framework.swift
│   ├── Keyboard.swift                                  │   │   ├── PlaceholderSpan.swift                           ├── GestureDetector.swift
│   ├── KeyboardKey.swift                               │   │   └── TextSpan.swift                                  ├── Image.swift
│   ├── Layer.swift                                     │   ├── TextPainter.swift                                   ├── Inherited.swift
│   ├── Painting.swift                                  │   ├── TextScaler.swift                                    ├── InheritedNotifier.swift
│   ├── Pointer.swift                                   │   └── TextStyle.swift                                     ├── Inherited_Old.swift
│   ├── Renderer.swift                                  ├── Physics                                                 ├── Scroll
│   ├── SwiftWidget.swift                               │   ├── PhysicsUtils.swift                                  │   ├── ScrollConfiguration.swift
│   ├── TargetPlatform.swift                            │   ├── Simulation.swift                                    │   ├── ScrollContext.swift
│   ├── TextEditing.swift                               │   ├── SpringSimulation.swift                              │   ├── ScrollController.swift
│   └── TextTypes.swift                                 │   └── Tolerance.swift                                     │   ├── ScrollMetrics.swift
├── Foundation                                          ├── Rendering                                               │   ├── ScrollPhysics.swift
│   ├── Array.swift                                     │   ├── Binding                                             │   ├── ScrollPosition.swift
│   ├── Assertion.swift                                 │   │   └── RendererBinding.swift                           │   ├── ScrollPositionWithSingleContext.swift
│   ├── Box.swift                                       │   ├── LayoutHelper.swift                                  │   ├── Scrollable.swift
│   ├── Callback.swift                                  │   ├── MouseTracker.swift                                  │   ├── ScrollableHelpers.swift
│   ├── ChangeNotifier.swift                            │   ├── MouseTrackerAnnotation.swift                        │   └── SingleChildScrollView.swift
│   ├── Collections.swift                               │   ├── RenderBox.swift                                     ├── Shortcuts.swift
│   ├── Constants.swift                                 │   ├── RenderColoredBox.swift                              ├── TapRegion.swift
│   ├── Diagnostics.swift                               │   ├── RenderEditable.swift                                ├── Text
│   ├── Equality.swift                                  │   ├── RenderFlex.swift                                    │   ├── EditableText.swift
│   ├── Math.swift                                      │   ├── RenderImage.swift                                   │   ├── Text.swift
│   ├── MatrixUtils.swift                               │   ├── RenderMouseRegion.swift                             │   ├── TextEditingIntents.swift
│   ├── Number.swift                                    │   ├── RenderObject.swift                                  │   └── TextSelection.swift
│   ├── Print.swift                                     │   ├── RenderParagraph.swift                               ├── TickerProvider.swift
│   ├── Stopwatch.swift                                 │   ├── RenderPointerListener.swift                         ├── Transitions.swift
│   ├── String.swift                                    │   ├── RenderProxyBox.swift                                ├── ValueListenableBuilder.swift
│   └── Time.swift                                      │   ├── RenderShiftedBox.swift                              └── View.swift
├── Gestures                                            │   ├── RenderStack.swift                               24 directories, 168 files
│   ├── Arena.swift                                     │   ├── RenderView.swift
│   ├── ArenaTeam.swift                                 │   └── ViewportOffset.swift

为什么选择移植Flutter?

与 React Native 等复用系统 UI 组件的框架不同,自渲染框架内需要实现全部的 UI 绘制逻辑。这在显著提高了动画性能和渲染一致性的同时也显著增加了实现框架所需的代码量。直接复用 Flutter 代码让 Shaft 可以更专注于开发新特性,而非去重复造轮子。

未来计划

接下来 Shaft 的开发重点会放在支持更多平台,以及为 ShaftKit 增加更多开箱即用的 Widget。在这里也欢迎大家来一起参与项目开发,如遇任何问题欢迎在GitHub Issues中一起交流~