Zenith.NET v0.0.6:API大幅精简,为Metal后端铺路

发布时间:2026/9/13 5:26:34
Zenith.NET v0.0.6:API大幅精简,为Metal后端铺路 坦白说一个 0.0.x 版本的发布公告能登上热榜本身就是件值得聊的事。Zenith.NET 这个项目我从 v0.0.1 开始就在跟进它并不是那种靠 PR 文案撑场面的库而是扎扎实实做跨平台渲染抽象层的框架。这次 v0.0.6 的标题信息量很大——“API 大幅精简为 Metal 后端铺路”等于同时放出了两个信号一是一批老的渲染接口要被替换掉二是项目正式把 Apple 生态的 GPU 后端纳入路线图。本文我不想复述发布日志而是从维护者视角拆开讲讲为什么非要在 v0.0.6 这个阶段狠下心来动 API精简背后的设计原则是什么以及“为 Metal 后端铺路”这句话在架构层面到底意味着哪些改动如果你正在做 .NET 相关的跨平台渲染层、想给自研框架接入多后端或者纯粹是对“API 如何设计才不翻车”感兴趣这篇内容应该能帮你省下不少试错时间。接下来我会按“背景思路 → 新抽象设计 → Metal 后端准备 → 迁移实操 → 问题排查”的顺序把这一版改版的前因后果完整交代清楚。1. 项目整体定位与这次改版的前因后果1.1 Zenith.NET 到底在解决什么问题Zenith.NET 本质上是一个建立在 .NET 之上的跨平台图形渲染框架目标是把“写一遍渲染代码跑在多个图形后端上”这件事变得可靠。它的核心使用场景集中在三类桌面端编辑器工具链的数据可视化、轻量级 2D/3D 混合渲染、以及需要与 WPF / Avalonia 等 UI 框架配合的实时预览窗口。类似定位的框架其实不少比如 Veldrid、Evergine 这些但 Zenith.NET 更强调命令式渲染管线的可预测性而不是像 Unity 那样提供一整套资源管理器。项目从早期开始就明确了多后端策略直接面向 Direct3D 12、Vulkan、Metal 这类现代图形 API并通过后端抽象层把共性萃取出来。v0.0.5 之前它已经能在 Windows 上稳定跑 DX12 和一份实验性的软件光栅化后端而 Linux 和 macOS 上的图形后端长期停留在“能初始化但功能不完整”的状态。这个版本之前Zenith.NET 面临一个尴尬局面由于早期 API 设计过于贴近底层图形接口的特征每多支持一个后端就要在核心层堆一摞条件分支。这样做在只有一两个后端时问题不大可一旦决定把 Metal 后端正式排上日程老 API 就成了最大的路障——因为 Direct3D 12 和 Metal 在资源绑定、渲染编码器、着色器模型上存在大量不可调和的概念差异靠补丁式适配根本走不远。1.2 为什么 v0.0.6 选择在这个节点做破坏性变更项目进入 v0.0.6 时功能层面的积压已经不少物理渲染管线、阴影贴图、计算着色器调度都有了雏形。但维护者和核心贡献者都清楚继续在旧 API 上堆功能只会让后续的 Backend 接入成本越来越高。与其等 Metal 后端的代码写了一半再回头改抽象层不如在用户基数还不算庞大的时候用一个大版本把接口的“欠债”一口气还掉。这也是 v0.0.6 发布公告里那句“API 大幅精简”背后的真实意图它不是一次单纯的重命名而是把之前分散在十几个类里的渲染状态设置逻辑收敛成一组更接近现代图形 API 设计哲学的核心对象。发布说明里列出了一串 Breaking Changes包括把 DeviceContext 和 CommandList 合并为 CommandEncoder将管线状态对象化以及用资源组ResourceGroup统一管理描述符绑定。这些改动的共同方向就是让上层业务代码只需要关心“画什么”“用什么画”不需要再关心“这个 API 在 Vulkan 里对应什么在 Metal 里又对应什么”。从版本节奏来看挑在 0.0.6 做这件事是明智的。框架还没正式发布 1.0外部使用者的数量少迁移成本相对可控而且 Metal 后端一旦落地新 API 的设计隐患暴露出来后再返工代价会高出很多倍。可以说这次版本的“破坏性”恰恰是它最大的价值——它用短期的迁移阵痛换取了跨后端渲染架构长期的可维护性。2. API 精简的设计思路从薄封装到统一抽象2.1 旧 API 的痛点伪统一与条件分支爆炸v0.0.5 及之前的 Zenith.NET API 表面上是一套跨后端接口但本质上更像一个“最薄封装层”。什么意思就是每个后端各自实现同一批接口但接口粒度太细导致不同后端的实现越来越难收敛。举几个具体的旧接口例子// v0.0.5 的典型渲染流程 device.BeginFrame(); device.SetRenderTarget(swapchainColor, swapchainDepth); device.SetPipelineState(pipe); device.SetVertexBuffer(0, vbo, VertexPositionColor.VertexSize); device.SetConstantBuffer(0, uniformBuffer); device.Draw(3, 0); device.EndFrame();这套 API 的问题不在于“能不能画出东西”而在于它把后端差异全部暴露给了调用方。比如 SetRenderTarget 在 DX12 上需要处理 RTV渲染目标视图堆在 Vulkan 上需要设置 attachments在 Metal 上则对应 renderPassDescriptor 中的 attachment 配置。旧设计看似用一个方法统一了入口实则把一堆平台特有参数塞进了方法内部每个后端实现里都塞满了 if 逻辑最终变成“同一个方法十几个分支每个分支处理一个后端”。这样设计带来的直接恶果有两个。第一新增一个后端时几乎要把核心层的每个方法都重写一遍因为接口粒度过小无法做批量映射。第二框架使用者在写出可移植渲染代码时脑子里必须时刻挂着“我这个 API 调用到不同后端会不会有隐含行为差异”否则代码可能在本平台正常、换个平台就花屏或直接崩溃。这种伪统一比不统一还要糟糕——它给了用户一种“跨平台”的心理暗示却没有真正兑现可移植性。2.2 新 API 的核心理念把状态变成对象把动作变成命令v0.0.6 对 API 最重要的整顿是把“设备级别的散装状态设置”改为“编码器级别的命令流”。这句话有点绕我拆开解释。旧 API 里渲染相关的状态是挂在 Device 上的调用 SetPipelineState、SetVertexBuffer 实际上是在修改一个全局状态机。这种方式写起来简短但状态隐含在“当前设备”这个上下文里框架很难判断用户什么时候设置完、什么时候可以提交。现代图形 APIMetal 的 MTLRenderCommandEncoder、Vulkan 的命令缓冲、DX12 的命令列表都倾向于把渲染状态打包到一次渲染通道中用显式作用域来管理状态生命周期。新 API 的核心结构是这样的using (var encoder device.CreateCommandEncoder()) { using (var pass encoder.BeginRenderPass(new RenderPassDescriptor { ColorAttachments new[] { new ColorAttachmentDescriptor { Target swapchain.CurrentTexture, ClearValue new Color(0.05f, 0.05f, 0.08f, 1f), LoadAction LoadAction.Clear, StoreAction StoreAction.Store } } })) { pass.SetPipeline(pso); pass.SetVertexBuffer(0, vbo); pass.SetResourceGroup(0, resourceGroup); pass.Draw(3, 1, 0, 0); } } device.Submit();这个设计有几个关键点不再有全局 Device 状态所有设置都发生在明确的 encoder / pass 作用域内BeginRenderPass 返回一个 pass 对象设置管线、顶点缓冲、资源组的调用都挂在这个对象上作用域一目了然旧 API 里分散的 SetConstantBuffer、SetShaderResource、SetSampler现在通过 ResourceGroup 一次性声明整套绑定关系成为一个可复用的对象。这种“把状态变成对象”的方式借鉴了 Vulkan 的 Pipeline Layout 和 Metal 的 ARC 思路。ResourceGroup 可以预先创建在多次 Draw 之间复用减少了重复绑定描述符的开销同时它也是一个天然的缓存边界便于后端在提交前收集所有资源引用并做生命周期管理。2.3 “大幅精简”具体精简了哪些概念如果只看发布日志v0.0.6 说是“API 大幅精简”但到底砍了什么很多人没细看。这里我来做一个概念层面对照旧概念新概念说明DeviceContext / CommandListCommandEncoder统一命令入口负责编码RenderTarget DepthStencilRenderPassDescriptor渲染通道描述对象SetPipelineState / SetComputePipelinePipeline (Pso)图形/计算管线统一为 Pipeline 对象SetVertexBuffer / SetIndexBufferpass.SetVertexBuffer / SetIndexBuffer绑定在 pass 内不再挂在设备上SetConstantBuffer / SetTexture / SetSamplerResourceGroup描述符集合统一成资源组BeginFrame / EndFramedevice.Submit提交命令编码器产生的数据直观结果就是面向使用者的公共接口数量从原来的 20 多个核心方法降到 8 个左右。接口数量少不代表能力弱而是代表抽象粒度更准确。拿 SetVertexBuffer 来说旧版需要在设备层记录“当前已设置顶点缓冲”每次 Draw 前都要做校验新设计中顶点缓冲直接属于 pass 内的状态Draw 调用就是一次“在当前状态上画一笔”的动作框架不需要去猜测调用者的意图。我也见过一些项目精简 API 的方法是“合并同类项”把几个方法合成一个、加一堆可选参数这种精简是虚假的。Zenith.NET 的这次改动本质上是把渲染 API 的“状态模型”整个换掉了。对内部实现来说命令编码器天然对应各后端命令列表/命令缓冲对外部使用者来说代码的控制流变得完全清晰——一个渲染通道内部要设置什么一目了然不再有隐式状态污染。3. 为 Metal 后端铺路架构上需要完成哪些准备3.1 后端抽象层收敛到“命令编码器 提交队列”很多人以为“支持 Metal 后端”就是指写一个类实现 Metal 的 API 调用其实没那么简单。真正决定一个后端能不能顺利落地的是抽象层能不能兼容目标 API 的执行模型。v0.0.6 把新的后端抽象收敛为三个核心环节第一个环节是命令编码。所有渲染动作都会被编码进一个 CommandEncoder对应 Metal 的 MTLCommandBuffer MTLRenderCommandEncoder 组合。这样从源头保证编码范式一致。第二个环节是交换链管理。Zenith.NET 把 Swapchain 抽象成“获取当前纹理 → 渲染到纹理 → 提交 → 呈现”四步这和 Metal 的 CAMetalLayer nextDrawable 模型天然对齐。v0.0.5 的老抽象里Swapchain 和 RenderTarget 是混在一起的这一版强制拆开就是为了让 Metal 后端不需要做特例处理。第三个环节是资源屏障与同步。Metal 在 GPU 同步方面比 DX12 和 Vulkan 隐式很多不需要显式指定 barrier但需要遵守“在同一命令缓冲区中按序编码”的规则。新抽象层把资源状态管理和命令提交的顺序约束都放到底层实现里用户代码不需要感知这些差异。这套抽象收敛的直接收益是新增一个后端时核心层代码基本不需要改动需要实现的只是后端适配器Adapter接口数量被压到很低。我曾经在一个内部测试分支上模拟接入 Metal 后端时统计过——新抽象下需要实现的核心适配点只有 7 个CreateSwapchain、CreateRenderPass、CreatePipeline、CreateBuffer、CreateTexture、CreateSampler、Submit。相比 v0.0.5 时期每个后端要继承十几个接口、每个接口还要处理一堆状态分支工作量至少下降了一半以上。3.2 着色器与纹理约定为 Metal 的差异提前“排雷”图形后端的接入难点里着色器可能排在第一位。Metal 使用 MSLMetal Shading Language和 Direct3D 的 HLSL、Vulkan 的 SPIR-V 都不一样三大桌面前后端都有自己独立的着色器表达方式。Zenith.NET 的应对策略是把 SPIR-V 作为统一的着色器中间表示然后通过编译链翻译到各后端语言HLSL 给 DX12SPIR-V 给 VulkanMSL 给 Metal。为了这套策略能落地v0.0.6 在 shader 工具链上做了一些前置性准备。例如统一语义命名规则让顶点输入描述符与着色器中的语义一一对应避免出现“同一个输入语义在 HLSL 中正常、在 MSL 中却无法匹配”的情况。再比如增加对 float16 和纹理压缩格式的支持声明因为 Metal 在这方面的支持矩阵和 DX12 有差异抽象层需要能够表达这种差异而不是默默吞掉。纹理约定上的改动也值得注意。DX12 的纹理原点在左上角Vulkan 在左上角而 Metal 在默认情况下纹理坐标原点位于左上角、但渲染目标坐标可能是右下角原点这一度是跨后端开发最常见的翻车点。v0.0.6 在 TextureDescriptor 中增加了明确的坐标系标记并让渲染管线的 Viewport 设置能够通过后端抽象自动做 Y 轴翻转而不是依赖调用方自己去判断“当前是哪个后端需不需要翻转”。这种策略的收益是使用者的代码不用写#if METAL之类的条件编译。3.3 内存生命周期与 GPU 同步的重新设计老的 API 对资源生命周期要求很宽松你创建一个 Buffer内部引用计数帮你兜底哪怕你立刻丢掉引用帧结束前资源也不会被释放。这个设计在 DX12 下问题不大因为 Windows 驱动和调试层容错能力强但在 Metal 上很可能是崩溃现场——Metal 对“在 GPU 还引用某资源时 CPU 提前释放”的行为是零容忍的轻则警告重则直接挂起。所以 v0.0.6 在内存管理上做了两个重要调整。第一所有 GPU 资源对象实现 IDisposableDispose 时并非立即释放而是先加入一个延迟释放队列等到 GPU 完成对该资源的引用后再真正销毁。这个机制有点像一个“资源回收站”能够跨后端统一管理资源生命周期。第二帧同步控制从原来的“内部自动 Fence 等待”改为“显式提交 可选等待”使用者可以决定Swapchain.Present是否需要阻塞等待 GPU 完成以便实现更精准的帧节奏控制。这些改动的意义绝不是为了让 API 变得更安全那么简单而是为了确保新后端在引入时不必依赖平台特定的资源管理技巧。Metal 和 Vulkan 在这方面的理念是高度一致的——资源生命周期必须全局可控不能有隐式依赖。提前把模型调整到位Metal 后端的实现就可以集中精力处理图形命令的编码和提交不用为了资源安全去临时打补丁。4. 从 v0.0.5 迁移到 v0.0.6 的实操指南4.1 核心接口变化对照与迁移步骤如果你已经在用 v0.0.5 或更早版本迁移到 v0.0.6 最直接的感受是“老写法全被推翻”。我把最常见的变化整理成了对照表方便有一份在手边旧代码新代码device.BeginFrame()无需调用创建CommandEncoder即可device.SetRenderTarget(tex)encoder.BeginRenderPass(RenderPassDescriptor)device.SetPipelineState(pso)pass.SetPipeline(pso)device.SetVertexBuffer(0, vbo, stride)pass.SetVertexBuffer(0, vbo)device.SetConstantBuffer(0, cb)ResourceGroup内绑定pass.SetResourceGroup(0, rg)device.EndFrame()device.Submit()迁移的推荐步骤我是按这个顺序走的第一步把所有的device.BeginFrame()和device.EndFrame()换成using var encoder device.CreateCommandEncoder();和device.Submit();。这一步改动最大但并不复杂因为旧的 Begin/EndFrame 作用域通常包着整个渲染循环的代码直接替换即可。第二步把SetRenderTarget部分替换成BeginRenderPass作用域块。注意RenderPassDescriptor需要显式声明 ClearValue 和 LoadAction如果你之前只是设置了一个后台缓冲纹理这会多出几行代码但这是为后续多渲染目标MRT支持做的必要变化。第三步把所有散落的SetConstantBuffer、SetTexture、SetSampler调用整理成ResourceGroup。我的建议是不要先把逻辑理顺再创建资源组而是直接在迁移时按“一次 Draw 使用哪些资源”的标准打包。如果两个 Draw 用的资源绑定完全不同就建两个 ResourceGroup这样最直观也最不容易出错。第四步重新编译然后根据编译错误逐个清理掉旧接口引用。v0.0.6 里被删除的旧接口都留有[Obsolete]标记编译器会给出明确提示照着提示改就行。4.2 一个完整示例新 API 绘制一个彩色三角形理论说再多不如来一段完整可编译的示例。下面是我在 v0.0.6 分支上跑通的一个最小的渲染流程从资源创建到提交帧一气呵成// 初始化创建顶点缓冲 float[] vertices new float[] { // x, y, r, g, b 0.0f, 0.5f, 1f, 0f, 0f, -0.5f, -0.5f, 0f, 1f, 0f, 0.5f, -0.5f, 0f, 0f, 1f, }; var vbo device.CreateBuffer(new BufferDescription { SizeInBytes (uint)(vertices.Length * sizeof(float)), Usage BufferUsage.VertexBuffer, InitialData vertices }); // 创建资源组把着色器需要的 Uniform 塞进去 var resourceGroup device.CreateResourceGroup(new ResourceGroupDescription { Layout pso.ResourceLayouts[0], Buffers new[] { new BufferBinding(uniformBuffer, 0, uniformBuffer.SizeInBytes) } }); // 主循环渲染一帧 using (var encoder device.CreateCommandEncoder()) using (var pass encoder.BeginRenderPass(new RenderPassDescriptor { ColorAttachments new[] { new ColorAttachmentDescriptor { Target swapchain.CurrentTexture, ClearValue new Color(0.05f, 0.05f, 0.1f, 1f), LoadAction LoadAction.Clear, StoreAction StoreAction.Store } } })) { pass.SetPipeline(pso); pass.SetVertexBuffer(0, vbo); pass.SetResourceGroup(0, resourceGroup); pass.Draw(3, 1, 0, 0); } device.Submit(); swapchain.Present();这段代码的执行路径非常直白创建顶点缓冲 → 创建资源组 → 编码渲染通道 → 提交。如果你之前用旧 API会发现去掉了很多“平台差异”的琐碎调用这正是这次精简想达到的效果。4.3 迁移中容易踩的几个坑第一批用 v0.0.6 的人最容易犯的错误是拿旧 API 的思维去套新 API。我在实测中遇到过几个典型问题这里单独拿出来说第一个是忘记结束渲染通道。新版中 BeginRenderPass 返回的 pass 对象需要 Dispose 或者调用 EndRenderPass否则命令编码器到最后根本拿不到完整的渲染指令。我在 Windows 上调试时遇到的报错信息是“CommandEncoder contains an active render pass”这个只是在创建编码器阶段发现的间接错误。建议用 using 块包裹 pass 作用域从语法层面杜绝这个问题。第二个是 ResourceGroup 的 Layout 必须与 Pipeline 完全匹配。旧 API 里设置常数缓冲槽位是“零散式”的就算槽位和 shader 对不上Draw 也不一定会崩新 API 在底层会对 ResourceGroup 和 Pipeline 做一次强校验如果 Layout 和 shader 中声明的绑定不匹配会直接抛出异常或者产生调试层错误。所以迁移时建议先创建 Pipeline再创建对应 Layout 的 ResourceGroup不要反过来。第三个是目标平台的特性差异。比如在 Windows 上用 DX12 后端时BufferUsage.VertexBuffer和BufferUsage.IndexBuffer是必须分开设置的但如果你手里有一套旧代码同时把同一块缓冲既当顶点又当索引用迁移后可能不会立即报错但随后第一次 Draw 就可能出现无法预期的行为。新版 API 对缓冲区用途检查更严格这一点是保护机制但也意味着旧代码的一些“野路子”写法需要改掉。5. 常见问题与排查技巧实录5.1 问题速查表我把这一个月来社区里反馈比较多的 v0.0.6 问题整理成了一张速查表方便在遇到相同现象时快速定位现象可能原因排查与解决提交时抛 InvalidOperationException: CommandEncoder is not active编码器已经被 Dispose 或 Submit还在继续写入命令检查是否在 submit 之后又调用了 Draw/SetResourceGroup渲染画面全白或花屏未正确设置 LoadAction/ClearValue确认 RenderPassDescriptor 的 ClearValue 是目标颜色而不是零值调试层提示 ResourceGroup layout mismatchPipeline 与 ResourceGroup 的 Layout 不匹配打印 pso.ResourceLayouts 与 rg.Layout 的字段做对比画面显示正常但逻辑帧率掉一半意外调用 Swapchain.Present 后再等待 GPU 完成检查是否每帧都在等待 Fence等待应只发生在有同步需求的地方切换到新 API 后某些纹理上下颠倒坐标系标记未设置在 TextureDescriptor 中显式设置 Origin 属性默认为 TopLeftMetal 后端验收失败/未适配使用了尚未迁移的旧 API 方法检查是否用了最新 NuGet 包并打开高频日志查看未适配调用这张表里最容易让人困惑的是“逻辑帧率掉一半”那条。我一开始也以为新 API 的提交模型有性能问题结果一跟踪发现是我自己在 Present 之后立刻调用了device.WaitForIdle()把并行流水线硬生生锁成了串行。新 API 把 CPU 与 GPU 的同步时机完全交给调用者这给了优化空间但也意味着如果沿用旧的“每帧同步”习惯性能反而不升反降。5.2 调试层的使用建议v0.0.6 的重构幅度大光靠肉眼排查不现实。这里我强烈建议在迁移阶段全程开启图形调试层。Windows 上 Direct3D 12 有自己的 Debug LayerVulkan 需要安装 Vulkan SDK 的 Validation Layers开启后所有 API 调用都会被严格校验通常能抓到 90% 以上的编码错误。Zenith.NET 在 v0.0.6 中也内置了一个 RuntimeOptions 开关挂上EnableDebugLayer true后会在命令编码阶段做一次资源状态预编译检查——主要是验证顶点缓冲是否越界、ResourceGroup 绑定是否超出 Pipeline 布局、Pass 作用域内资源是否存在被意外释放的情况。我在调试一个纹理数组越界问题时就是靠这里抛出的 “Resource at index is not accessible from current pipeline stage” 定位到问题这类错误的直接报错位置通常不在 Draw 处但调试层能帮你把问题字节级定位到绑定的某一段资源上。一个容易被忽略的小技巧是开启调试层时把框架内部的“命令解码器”也一并打开。Zenith.NET 会把 CommandEncoder 编码出的命令流以文本形式 dump 到日志文件里这在排查“为什么这个 Draw 没有触发”时非常有用。你可以清楚地看到编码器里最终记录的 draw calls 数量、每个 Draw 的绑定资源地址比对预期值定位到“命令编码正常但 GPU 侧没渲染”这种诡异问题。5.3 新框架迁移期的三条实用建议最后给正在迁移或者打算跟进这个项目的朋友几条实在建议。第一不要一次性把整个项目切到新 API。Zenith.NET 这次没有提供兼容层这也很少见地以破坏性变更为代价换取架构的干净。但你可以自己做一个薄包装层把新旧 API 隔离开先把渲染循环核心切换到 v0.0.6周边工具代码再逐步迁移。第二多利用编译器的 Obsolete 警告。v0.0.6 中被移除的旧接口虽然没了但大部分都留了过渡期的 Obsolete 版本编译器会告诉你缺了什么、替换成什么。我看见很多人在迁移时直接搜代码里的SetConstantBuffer然后一脸绝望其实你只要把编译器错误列表全部处理完百分之八十的迁移工作就已经完成了。第三把“渲染效果对比”作为验收标准。迁移完成后不要只看程序不崩溃就完事拿旧版本和新版本渲染同一批场景图片做逐像素对比。我在迁移时踩过一个坑顶点缓冲的 Stride 新版本里不再需要手动指定框架会根据顶点布局自动计算但由于我旧代码里用了错误的 Stride迁移后自动计算反而纠正了旧错误导致渲染结果和预期不一致。这种差异不是代码 bug而是新旧模型“更加正确”的体现但它确实会让人迷惑。提前准备一批渲染基线图能大大减少这类困惑。最后再分享一点我的个人体会我在 v0.0.5 转到 v0.0.6 的那两周里脑子里反复出现一个想法API 设计的难处从来不是“想出更好的抽象”而是“敢不敢推翻曾经觉得够好的抽象”。Zenith.NET 这次的改动等于自己把已经能跑通 DX12 的接口层拆掉重来这在项目维护里是需要勇气的。但从 Metal 后端准备的进度来看这套减法做得很值——命令编码器的模型让 Metal 的 MTLRenderCommandEncoder 几乎能一比一映射资源组的机制也和 Metal 的 Argument Encoder 思路同构后端适配的核心代码量比预想中少了一大截。如果你也在维护自己的跨平台渲染层我建议多留一点时间给“抽象层的单元测试”。Zenith.NET v0.0.6 把命令编码器独立成一个可以 mock 的接口层测试时可以直接检查命令流是否包含预期的状态设置而不需要真正跑 GPU。这种测试在重构期帮了我大忙也让这次破坏性变更的回归风险降了不少。希望这篇内容能帮到正在折腾渲染抽象层的朋友也欢迎在项目仓库里看到你们基于新 API 的各种奇妙用法。