Repository Wiki
BeyondDimension/SteamTools

Avalonia 源码定制与字体渲染

本文介绍 SteamTools(本仓库)中 src/Avalonia.Skia.Internals 子系统:它是通过对 Avalonia.Skia(Avalonia 的 Skia 渲染后端)源码进行"内部分叉/定制"而得到的一组基础设施代码,核心目标是替换默认字体管理实现,为应用提供自定义默认字体,并在此基础上暴露若干 Skia 平台层扩展点(渲染接口、位图、绘制上下文扩展、桌面生命周期等)。

Purpose and Scope

本页覆盖范围:

  • Avalonia.Skia.Internals 项目整体定位与设计动机(为何要以 extern alias 方式内部分叉 Avalonia.Skia 源码)
  • 字体渲染定制链路:IFontManagerImpl2 默认接口方法的委托实现与 SKTypefaceCollection / SKTypefaceCollectionCache 备份代码
  • 该目录下其余平台层扩展文件的角色说明(PlatformRenderInterface、SkiaPlatform2、ImmutableBitmap、DrawingContextExtensions、ClassicDesktopStyleApplicationLifetime、SkiaSharpHelpers)

不属于本页的内容(留给兄弟页面):

  • Avalonia 应用的整体启动流程、DI 容器与页面导航 → 属于应用初始化相关页面
  • 具体的界面控件与 XAML 视图 → 属于 UI 控件相关页面
  • SkiaSharp 业务侧绘图(如托盘图标、验证码等)→ 属于各自功能页面

说明:本次文档生成时源码探索预算(6 次工具调用)已耗尽,PlatformRenderInterface.cs、SkiaPlatform2.cs 等文件未逐一读取,正文会对已验证部分给出精确源码引用,对未读取部分仅作边界说明而不臆测其实现细节。

Overview

Avalonia 官方包 Avalonia.Skia 内部的 Avalonia.Skia.FontManagerImpl 等类型是 internal 的,第三方应用无法直接继承或替换。为了让 SteamTools 能够:

  1. 在不等待上游官方支持的情况下自定义"默认字体族"(这对中文等多语言界面尤其重要,默认字体直接影响文本渲染效果与回退行为);
  2. 保留官方 Skia 后端的全部原生行为(字体回退、字符匹配、流式字体加载),只对"familyName 进入字体创建前"这一步做拦截;

本仓库采用了一种接口默认实现 + 外部程序集别名的做法:把 Avalonia.Skia 作为 extern alias 引入,然后在 Avalonia.Platform 命名空间下定义新接口 IFontManagerImpl2,用接口默认成员(C# default interface members)把官方 IFontManagerImpl 的方法全部以委托方式转发到内部持有的 Impl(即官方 FontManagerImpl 实例),仅在两个关键点插入自定义逻辑:

  • GetDefaultFontFamilyName() → 直接返回自定义的 DefaultFontFamilyName
  • TryCreateGlyphTypeface(familyName, ...) → 先经过 OnCreateGlyphTypeface(familyName) 改写字体族名,再交给官方实现

文件头部注释直接说明了意图:

csharp
1// Replace font management to implement custom default font 2extern alias AvaloniaSkia; 3 4using Avalonia.Media; 5using FontManagerImpl = AvaloniaSkia::Avalonia.Skia.FontManagerImpl;

Source: IFontManagerImpl2.cs

Architecture

Loading diagram...

图中各部分的真实对应关系:

  • IFontManagerImpl2 位于 Avalonia.Platform 命名空间(通过 // ReSharper disable once CheckNamespace 抑制命名空间告警),继承自官方 IFontManagerImpl,因此任何期望 IFontManagerImpl 的调用点都能接受它的实现类。
  • Impl 属性是 protected 的抽象入口,静态工厂 CreateFontManager() 直接 new FontManagerImpl()(官方 Skia 实现类型通过别名 AvaloniaSkia::Avalonia.Skia.FontManagerImpl 引用)。
  • SKTypefaceCollection.cs 与 SKTypefaceCollectionCache.cs 当前被整文件注释掉,属于"从上游拷贝源码后预留"的包装层,为后续按 FontFamily 缓存字形集合留好扩展位。

为什么用 extern alias + 接口默认实现

Avalonia.Skia 的 FontManagerImpl 没有 public 构造扩展点,也不允许子类替换默认字体族。本仓库的做法等价于"以接口默认方法实现一层装饰器":

  • 不 fork 整个 Avalonia 渲染栈:官方 FontManagerImpl 仍然是真正的执行者,兼容性风险最小;
  • 只拦截两个语义明确的切点:默认字体族名称(展示层选择)与 glyph typeface 创建前的 familyName 改写(实际加载层);
  • 通过 extern alias 解决命名冲突:定制代码与上游类型同名同命名空间时,用别名限定符 AvaloniaSkia:: 精确指向官方程序集内的类型,避免 CS0433 类型歧义。

核心实现分析:IFontManagerImpl2

接口契约与默认实现

IFontManagerImpl2 使用 C# 默认接口成员(default interface methods)直接在接口上提供实现,任何实现该接口的类无需重复编写转发代码:

csharp
1public interface IFontManagerImpl2 : IFontManagerImpl 2{ 3 protected IFontManagerImpl Impl { get; } 4 5 string DefaultFontFamilyName { get; } 6 7 string OnCreateGlyphTypeface(string familyName); 8 9 string IFontManagerImpl.GetDefaultFontFamilyName() => DefaultFontFamilyName;

Source: IFontManagerImpl2.cs

三个成员的职责分工:

成员类别作用
Implprotected 属性持有被包装的官方 IFontManagerImpl(实际为 Skia 的 FontManagerImpl),所有默认实现都向它转发
DefaultFontFamilyName抽象属性由具体实现类提供自定义默认字体族名,GetDefaultFontFamilyName() 直接返回它
OnCreateGlyphTypeface(familyName)抽象方法字体族名改写钩子:在真正创建字形字体前调用,可把任意 familyName 重映射为自定义字体

familyName 改写切点(关键路径)

这是整个定制里唯一改变字体解析结果的地方:

csharp
1bool IFontManagerImpl.TryCreateGlyphTypeface(string familyName, FontStyle style, FontWeight weight, FontStretch stretch, [NotNullWhen(returnvalue: true)] out IGlyphTypeface? glyphTypeface) 2{ 3 familyName = OnCreateGlyphTypeface(familyName); 4 return Impl.TryCreateGlyphTypeface(familyName, style, weight, stretch, out glyphTypeface); 5}

Source: IFontManagerImpl2.cs

控制流解释:当 Avalonia 文本系统要把一个 Typeface 解析成 IGlyphTypeface 时,会调用 IFontManagerImpl.TryCreateGlyphTypeface(familyName, ...);经由本接口默认实现,familyName 先被 OnCreateGlyphTypeface 改写(例如统一替换为应用内置字体名),再交给官方 Impl 完成真正的匹配与加载。[NotNullWhen(returnValue: true)] 保证调用方在返回 true 时可安全认为 glyphTypeface 非空。

纯转发成员

以下成员完全透传官方实现,不做任何加工,保持官方行为不变:

csharp
1bool IFontManagerImpl.TryMatchCharacter(int codepoint, FontStyle fontStyle, FontWeight fontWeight, FontStretch fontStretch, CultureInfo? culture, out Typeface typeface) 2{ 3 return Impl.TryMatchCharacter(codepoint, fontStyle, fontWeight, fontStretch, culture, out typeface); 4} 5 6bool IFontManagerImpl.TryCreateGlyphTypeface(Stream stream, FontSimulations fontSimulations, [NotNullWhen(returnvalue: true)] out IGlyphTypeface? glyphTypeface) 7{ 8 return Impl.TryCreateGlyphTypeface(stream, fontSimulations, out glyphTypeface); 9}

Source: IFontManagerImpl2.cs

需要注意的一个细节:GetInstalledFontFamilyNames(bool checkForUpdates) 同时提供了显式接口实现与 new 隐藏版本两个入口(两者都转发到 Impl),这是为了让实现类既满足接口契约,又能在以具体类型调用时得到更直接的签名。

工厂方法与被注释掉的预留 API

csharp
1//IGlyphTypeface IFontManagerImpl.CreateGlyphTypeface(Typeface typeface) 2//{ 3// typeface = OnCreateGlyphTypeface(typeface); 4// return Impl.CreateGlyphTypeface(typeface); 5//} 6 7protected static IFontManagerImpl CreateFontManager() => new FontManagerImpl();

Source: IFontManagerImpl2.cs

被注释掉的 CreateGlyphTypeface(Typeface) 说明作者曾计划在"按 Typeface 对象创建字形字体"的旧 API 上同样挂接改写钩子,但当前上游契约已以 TryCreateGlyphTypeface(familyName, ...) 为准,因此该路径被停用,仅保留注释作历史记录。CreateFontManager() 是静态工厂,保证默认的 Impl 永远是官方 Skia FontManagerImpl 实例。

SKTypefaceCollection 与缓存层(预留代码)

SKTypefaceCollection.cs 与 SKTypefaceCollectionCache.cs 两个文件目前整体处于注释状态,它们是从上游 Avalonia.Skia 拷贝来的源码骨架,用于后续按 FontFamily 缓存 SKTypefaceCollection:

csharp
1//public class SKTypefaceCollectionCache 2//{ 3// /// <summary> 4// /// Gets the or add typeface collection. 5// /// </summary> 6// /// <param name="fontFamily">The font family.</param> 7// /// <returns></returns> 8// public static SKTypefaceCollection GetOrAddTypefaceCollection(FontFamily fontFamily) 9// { 10// return new(@this.GetOrAddTypefaceCollection(fontFamily)); 11// } 12//}

Source: SKTypefaceCollectionCache.cs

从注释可还原其设计意图:GetOrAddTypefaceCollection(FontFamily) 采用"不存在则添加"的缓存模式(Get-or-Add),包装层构造 new(...) 时把上游(@this 别名指向官方 Avalonia.Skia.SKTypefaceCollectionCache)返回的集合再包一层,属于典型的装饰器 + 缓存结构。这部分在当前源码状态下未启用,属于扩展点而非现行行为。

Core Flow:一次字体解析的完整链路

Loading diagram...

配套的默认字体族查询路径更简单:GetDefaultFontFamilyName() 不经过 Impl,直接返回 DefaultFontFamilyName,因此自定义默认字体在"未显式指定 FontFamily 的控件"上立即生效。

该目录其他扩展文件的角色边界

以下文件属于同一个"内部定制"子系统,本页仅界定其职责(本次生成未逐行读取,故不展开内部实现):

文件推断职责(基于命名与目录归属)
PlatformRenderInterface.cs / SkiaPlatform2.cs包装/扩展 Skia 平台渲染接口初始化
ImmutableBitmap.cs不可变位图的 Skia 实现
DrawingContextExtensions.cs绘制上下文的扩展方法
ClassicDesktopStyleApplicationLifetime.cs定制桌面应用生命周期
SkiaSharpHelpers.csSkiaSharp 互操作辅助

这些文件共同构成"对上游 Avalonia.Skia 源码做选择性内部分叉"的基础设施层;字体管理只是其中已完整落地并启用的一环。

API Reference

IFontManagerImpl2.DefaultFontFamilyName(抽象属性)

自定义默认字体族名,IFontManagerImpl.GetDefaultFontFamilyName() 直接返回该值。

  • 类型:string
  • 由实现类提供;接口默认实现不给出具体值。

IFontManagerImpl2.OnCreateGlyphTypeface(string familyName)(抽象方法)

字体族名改写钩子,在把 familyName 传给官方 Impl.TryCreateGlyphTypeface 之前调用。

Parameters:

  • familyName (string):Avalonia 文本栈请求解析的原始字体族名。

Returns: 改写后的字体族名,将用于真正的字形字体匹配。

IFontManagerImpl2.CreateFontManager()(protected static)

Returns: IFontManagerImpl —— 官方 Avalonia.Skia.FontManagerImpl 实例,作为所有默认转发实现的目标。

接口默认实现成员(经 Impl 转发)

官方接口成员定制行为
string GetDefaultFontFamilyName()返回 DefaultFontFamilyName(不转发)
string[] GetInstalledFontFamilyNames(bool checkForUpdates)转发 Impl
bool TryMatchCharacter(int, FontStyle, FontWeight, FontStretch, CultureInfo?, out Typeface)转发 Impl
bool TryCreateGlyphTypeface(string, FontStyle, FontWeight, FontStretch, out IGlyphTypeface?)先 OnCreateGlyphTypeface 改写,再转发 Impl
bool TryCreateGlyphTypeface(Stream, FontSimulations, out IGlyphTypeface?)转发 Impl

Failure Modes, Edge Cases & Concurrency

基于已读取源码可确认的行为边界:

  • 改写失败不产生异常:OnCreateGlyphTypeface 只改写字符串,真正的成败由 Impl.TryCreateGlyphTypeface 的 bool 返回值表达;若改写后的 familyName 无法匹配,返回 false 且 glyphTypeface 为 null([NotNullWhen(returnValue: true)] 契约),Avalonia 文本栈按官方逻辑继续降级/回退。
  • 字符级回退不受影响:TryMatchCharacter 是纯转发,DefaultFontFamilyName/OnCreateGlyphTypeface 的自定义不会破坏按 codepoint 的字体回退匹配。
  • 流式字体加载不受影响:TryCreateGlyphTypeface(Stream, ...) 纯转发,资源内嵌字体加载路径保持官方行为。
  • 命名冲突处理:与上游同名类型共存依赖 extern alias AvaloniaSkia 与 AvaloniaSkia:: 限定符;若升级 Avalonia 版本,需要核对 Avalonia.Skia.FontManagerImpl 的构造与签名是否仍兼容(CreateFontManager() 直接 new,是其唯一耦合点)。
  • 并发:接口默认实现本身无共享可变状态(每次调用只改写局部 familyName),并发安全性完全取决于内部 Impl(官方 FontManagerImpl)自身的线程模型;本层未额外加锁。

Performance & Operational Notes

  • 零额外开销设计:除两次字符串赋值与一次虚方法调用(OnCreateGlyphTypeface)外,其余路径均为 1:1 转发,未引入重复字体枚举或额外 IO。
  • 缓存扩展预留:SKTypefaceCollectionCache.GetOrAddTypefaceCollection(当前注释)指向的 Get-or-Add 缓存模式,是后续减少重复构建 SKTypefaceCollection 的官方思路;启用时需注意以 FontFamily 为 key 的缓存失效(源文件中 GetInstalledFontFamilyNames(bool checkForUpdates) 的 checkForUpdates 参数即服务于系统字体更新场景)。
  • 运维注意:自定义默认字体一旦设置,所有未显式指定 FontFamily 的文本都会走 OnCreateGlyphTypeface 链路;排查"字体不生效"时应先确认 DefaultFontFamilyName 是否能被官方 FontManagerImpl 成功解析(即是否真实安装/已注册)。

Extension Points

  • 实现 IFontManagerImpl2:提供 DefaultFontFamilyName 与 OnCreateGlyphTypeface,并把实例注册为平台字体管理器,即可接入整套定制链路(接口默认实现已承担全部转发工作)。
  • 启用 SKTypefaceCollection 包装层:取消注释并补齐 extern alias,可实现按 FontFamily 的字形集合缓存包装。
  • 同目录平台层文件:PlatformRenderInterface、SkiaPlatform2 等提供了在同一 extern alias 模式下扩展渲染平台初始化的挂载点。