RakuLLApp 的界面同时包含英文、简体中文、繁体中文和日文。Flutter Web 使用 CanvasKit/Skwasm 时文字由引擎光栅化,CSS @font-face 无法喂给 Flutter 场景,字体字节必须通过 Dart 的 FontLoader 注册进引擎——但字节从哪里下载 不受限制。因此应用把全部 7 个字体文件移出首帧 FontManifest,改为运行时注册:
  • Web 端:业务字体字节只来自公共 CDN 镜像,不由自家服务器同源传输。 7 个 ttf 在 pubspec 里用 platforms: 限定为仅原生平台,Web 构建产物中 一个业务 ttf 都没有build/web/assets/assets/fonts/ 只剩 4 个 OFL 授权文本);运行时按语言从 jsDelivr 的多个公共镜像边缘懒加载。
  • 原生端:7 个 ttf 全部打进 app bundle,main() 从本地注册,零运行时 网络访问、可离线使用。
Web 与原生允许使用不同的字体文件(见下文「Web/原生文件差异」),例如 Noto Serif JP 的 Web 版是可变字体、原生版是静态 Regular,霞鹜文楷 Web 用 Lite 版、原生用全量版。

当前策略

字体分为“界面字体”和“阅读字体”两组,均不进入 pubspec 的 fonts: 段 (因此不进入 FontManifest.json、不阻塞首帧),而是以普通 assets 发布并在 运行时通过 FontLoader 注册:
  • 界面字体(Web 首帧前)main()runApp 前按当前 Locale 调用 UiFontLoader.ensureFor(),只下载该语言需要的界面字体。
  • 界面字体(原生)main() 调用 UiFontLoader.registerAllBundled(), 从本地 bundle 一次性注册全部 4 个界面字体,不产生任何网络请求。
  • 阅读字体(按需):文章库实际呈现后的下一帧按当前语言静默预取,进入阅读器 或语法库时由加载门兜底等待。预取与加载门共享同一并发任务及成功缓存。
  • 切换语言:语言切换器先 ensureFor() 新语言的界面字体,成功后才提交 Locale;失败则保留原语言并弹出“重试 / 取消”,避免出现缺字框(tofu)。
Web 端字体字节由 web/raku_fonts.js(零依赖、在 flutter_bootstrap.js 之前同步加载)负责选源与下载。默认源是 jsDelivr 的三个公共镜像边缘, 开箱即用、无需任何运维配置
DEFAULT_SOURCES = [
  { id: "jsdelivr", mirror: "https://cdn.jsdelivr.net/" },
  { id: "fastly",   mirror: "https://fastly.jsdelivr.net/" },
  { id: "gcore",    mirror: "https://gcore.jsdelivr.net/" },
]
  1. Flutter 引擎启动的同时,对每个源并发发起 16 KB Range 探测 (探测文件为 Inter),按真实耗时排名,国内用户和海外用户各自命中最快的 可达边缘;探测失败或超时的源排在最后。
  2. fetchFont(family) 按排名逐源尝试,网络错误、CORS 拒绝或非 2xx 状态码 自动 failover 到下一个镜像。
  3. 源有两类:mirror 源拼接仓库路径 token(默认的 jsDelivr 镜像走 gh/google/fonts@main/…gh/lxgw/LxgwWenKai-Lite@main/…); baseUrl 源则在给定目录下按扁平文件名取字(私有 CDN / 同源用)。 应用不再自动追加同源兜底——同源目录里本来就没有 ttf;只有在 window.rakuFontConfig 中显式配置时,同源/私有源才会参与。
页面在 Flutter 和 CanvasKit 初始化期间先显示 web/index.html 内联的“正在加载资源” 画面。该画面只使用 HTML、CSS 与系统字体,不会额外下载图片或字体;字体下载期间其状态行会聚合显示 正在加载字体 N% · Loading fonts N%,收到 flutter-first-frame 事件后自动移除,初始化失败则显示重试按钮。
字体按文字系统分工:
  • 界面拉丁字母与数字:Inter(可变)。非日文界面以 Inter 为主字体,避免英文/数字 套用日文黑体中偏宽的拉丁字形。
  • 界面日文:Noto Sans JP(可变,wght 轴 100–900)。
  • 界面简/繁中文:Noto Sans SC / Noto Sans TC(可变),作为 Inter 与日文字体的 CJK 回退,按地区排在回退链最前。
  • 阅读正文:日文原文与注音用 Noto Serif JP(源ノ明朝);中文译文/释义用霞鹜文楷 LXGW WenKai(简 LxgwWenkai、繁 LxgwWenkaiTC)。

各语言实际下载的字体

这是日语学习 App,所有语言界面都可能出现日文例句,因此每种语言的集合都包含 Noto Sans JP / Noto Serif JP。
Locale界面字体(首帧前)阅读字体(文章库出现后)
English / 日本語Inter、Noto Sans JPNoto Serif JP
简体中文Inter、Noto Sans SC、Noto Sans JPNoto Serif JP、LXGW WenKai(简)
繁體中文Inter、Noto Sans TC、Noto Sans JPNoto Serif JP、LXGW WenKai TC(繁)
界面字体全部使用可变字重版:三份 Noto Sans 的 wght 轴都覆盖 100–900,主题里的 Regular(400)、Medium(500)、Semibold(600) 都能取到真实设计字重。早期每个地区只 打包一份静态 Regular,标题、按钮、分段标签请求中粗字重时,引擎只能对常规字形 “描边”做人造加粗;中文笔画密集,描边后粘连、发糊,看起来比可变的英文明显更粗。 三份 Noto Sans 可变字体在构建压缩前合计 39,304,168 字节(旧静态 Regular 合计 18,547,732 字节)。版本、来源 URL、单文件大小和 SHA-256 记录在字体目录的 README.md

Web 与原生的文件 / 字节差异

两端注册机制相同,但允许使用不同的字体文件,AppFonts._fontFiles 管原生 bundle 文件名,web/raku_fonts.jsFONT_FILES 管 Web 的仓库 token 与字节数;Dart 侧 ReadingFontAssetnativeBytes / webBytes 两个字段登记,expectedByteskIsWeb 取当前平台的值(仅用于服务端不返回 Content-Length 时驱动进度条):
字体原生(包内)Web(公共 CDN)
Inter / Noto Sans JP / SC / TC同一份可变 ttf同一份可变 ttf(gh/google/fonts
Noto Serif JP静态 Regular,8,080,136 B可变版 NotoSerifJP[wght].ttf,13,574,352 B
霞鹜文楷(简/繁)全量 LXGWWenKai*-Regular.ttfLite 版 LxgwWenKai-Lite,13,872,424 B;生僻字由 Noto 回退兜底
Google 打包的这些可变字体,其「默认实例」名常是 Thin / ExtraLight (OS/2 字重 100/200)。这不代表画出来是细线体:CanvasKit 会把 FontWeight 映射到 Skia 字重枚举并沿 wght 轴插值,请求 w400 / w600 就取真实的 Regular / SemiBold。release 构建下实测日文界面 Regular 正文与 SemiBold 标题字重正常、不发虚。排查「日文发虚」时不要被默认实例名误导, 真正要确认的是字体是否注册成功(debug 的自定义 bootstrap 会被引擎覆盖, 字体问题必须用 release 构建验证)。
位置职责
rakullapp_core/assets/fonts/字体二进制、来源说明、SHA-256 与 OFL 授权文本。仅供原生 bundle;Web 构建后 assets/assets/fonts/ 只剩 4 个 OFL 文本,无任何 ttf
rakullapp_core/pubspec.yaml7 个 ttf 用 - path: + platforms: [android, ios, macos, windows, linux] 限定为仅原生平台(Web 被排除);4 个 OFL 文本保留全平台(Web 的许可页要读);不存在 fonts:,任何字体都不进首帧 FontManifest
web/raku_fonts.jsWeb 选源与下载:默认三个 jsDelivr 公共镜像、多源并发 Range 探测、按耗时排名、逐源 failover、进度聚合、同 family 并发去重;暴露 window.rakuFonts.fetchFont() 给 Dart
web/index.htmlflutter_bootstrap.js 之前同步引入 raku_fonts.js;也可在此注入 window.rakuFontConfig 覆盖源(公司自有 CDN / 私有部署),无需重新构建
web/flutter_bootstrap.js使用本地自托管 CanvasKit(canvaskit/),并把引擎字体回退目录指向 assets/fallback_fonts/(release 构建才生效,debug 的 bootstrap 会被引擎覆盖)
lib/shared/theme/app_fonts.dartfamily→文件名映射、Locale 字体矩阵、日文正文语义名、授权注册(字体命名的唯一真相源)
lib/shared/theme/font_source.dart / font_source_native.dart / font_source_web.dart条件导出:原生端从 AssetBundle 读取字节;Web 端经 dart:js_interop 调用 window.rakuFonts.fetchFont()
lib/shared/theme/ui_font_loader.dart按 Locale 首帧前注册界面字体(Web)或一次性注册全部内置界面字体(原生);缓存成功结果、合并并发、失败可重试
lib/shared/theme/reading_font_loader.dart按 Locale 加载并用 FontLoader 注册阅读字体,汇总字节/字体进度、缓存成功结果、合并并发请求并允许失败重试
lib/shared/widgets/language_switcher.dart切换语言前先确保新语言界面字体已注册,失败不切换并提供重试/取消
lib/app/dashboard_screen.dart文章库呈现后的下一帧预取当前 Locale 所需阅读字体;切换语言时只补载新字体,并把同一加载器交给阅读器
lib/immersive_study/widgets/reader/通过 ReaderTypography 按语言选择正文(日文 Noto Serif JP、中文霞鹜文楷),并通过 AppFonts.japaneseText 标记日文原文与注音
业务组件不得直接写 'Roboto''NotoSansJP''NotoSansSC' 等字体族字符串。 界面字体使用主题;阅读正文通过 ReaderTypography,其他有明确语言语义的内容使用 AppFonts 中的语义常量或 Locale 映射。

Web CDN 部署配置

默认无需任何配置:开箱即用走 jsDelivr 三个公共镜像(cdn / fastly / gcore),探测选最快边缘、失败自动切换,字体字节全部来自 CDN, 自家 Web 服务器不传任何业务字体。 需要改用公司自有 CDN(或私有部署、内网镜像)时,在 web/index.html 引入 raku_fonts.js 之前注入 window.rakuFontConfig 即可覆盖默认源, 不必重新构建前端。源分两类:
  • mirror:镜像根地址,取字时拼接 FONT_FILES 里的仓库 token。适合自建 jsDelivr/仓库镜像:
    sources: [{ id: "corp", mirror: "https://cdn.your-company.net/" }]
    
  • baseUrl:一个平铺目录,取字时拼接 FONT_FILESfile 文件名。适合 直接把字体对象存进自家对象存储 / CDN:
    <script>
      window.rakuFontConfig = {
        sources: [
          { id: "cn", baseUrl: "https://cdn.example.cn/fonts/v1/" },
          { id: "global", baseUrl: "https://cdn.example.com/fonts/v1/" },
        ],
      };
    </script>
    
空值、占位符(含 REPLACE-ME)、重复源会在运行时自动丢弃;不会再自动 追加同源目录(同源里本就没有 ttf)。国内、海外边缘按需列出,只存在一个就只 列一个;列出的源完全替换默认的三个公共镜像。 baseUrl 类源的 CDN 侧要求:
  • 同名扁平结构提供每个 FONT_FILES.file(即 <baseUrl>/Inter-Variable.ttf<baseUrl>/NotoSansJP-Variable.ttf 等, 文件名必须与 FONT_FILESfile 完全一致);
  • 注意 Web 与原生可不同文件:NotoSerifJP-Regular.ttf 这个名下要放 可变版(13,574,352 B)、LXGWWenKai-Regular.ttf / LXGWWenKaiTC-Regular.ttf 要放 Lite 版(13,872,424 B),且 FONT_FILES.size 要与实际字节一致;
  • 对字体 GET 返回 CORS 头 Access-Control-Allow-Origin(探测与下载均带 mode: "cors"Range 头,且需支持 206 分片);
  • 长缓存:Cache-Control: public, max-age=31536000, immutable。字体更新时 抬升路径版本段(/fonts/v1//fonts/v2/)而不是覆盖同名文件;
  • ttf 直接透传、不做边缘压缩(进度按原始字节统计)。
自家源站不需要为字体配置同源缓存/压缩——它根本不服务业务字体,同源路径下 只有 4 个 OFL 文本。CanvasKit 引擎自身资源(canvaskit/、引擎字体回退目录 assets/fallback_fonts/)仍随构建目录同源托管。
引擎拉丁兜底 Roboto:当前 release 观测到 CanvasKit 在尚未命中业务字体时, 可能向 fonts.gstatic.com 请求一个仅含拉丁字符的 Roboto woff2 作为引擎 兜底;它不承载任何日文/中文字形,业务字体全部来自 jsDelivr。如需做到 完全无第三方请求,可在 assets/fallback_fonts/ 自托管一份小体积 Roboto (引擎通过 fontFallbackBaseUrl 读取)——该项与业务字体 CDN 化相互独立, 属可选的后续加固。

添加或替换字体

  1. 只从字体项目的官方发行渠道取得文件,并确认应用分发方式符合字体授权。
  2. 把字体放入 rakullapp_core/assets/fonts/,同时保存完整授权文本。
  3. 在该目录的 README.md 记录版本、官方来源 URL 和 SHA-256。
  4. AppFonts 添加族常量,并在 _fontFiles 登记 family→原生文件名; 同步在 web/raku_fonts.jsFONT_FILES 登记 filebaseUrl 平铺 文件名)、tokenmirror 仓库路径)与 size(Dart 与 JS 的 family 键名必须严格一致)。阅读字体还要更新 ReadingFontAssetnativeBytes / webBytes(无 Content-Length 时驱动进度条);若 Web 与 原生用不同文件,两个字节数分别填。
  5. AppFonts.uiFontFamiliesFor / ReadingFontLoader.assetsFor 更新 Locale 矩阵。日文正文专用字体只需修改 japaneseText,不要逐个修改阅读器组件。
  6. 不要把任何字体加回 pubspec 的 fonts: 段,否则 Flutter 会在首帧前下载。 新 ttf 以 - path: + platforms: [android, ios, macos, windows, linux] 列入 assets排除 web);授权文本(OFL)则不加 platforms、保留 全平台。界面字体优先使用可变字重文件,且不要给可变资产写静态 weight, 否则引擎无法按轴插值。
  7. 若增加授权文件,在 AppFonts.registerLicenses() 中注册,确保 Flutter License UI 能展示它。
  8. 若使用默认公共镜像,确认 FONT_FILES.token 指向的仓库对象可公开访问;若用 自有 baseUrl,把对应字节按 file 同名扁平结构上传到所有源后再发布。
  9. 运行格式化、静态分析、测试、Node 选源自测与 release Web 构建;确认 FontManifest.json 不含任何应用字体、raku_fonts.jsindex.html 引用、build/web 下不存在任何业务 ttfassets/assets/fonts/ 仅 OFL*.txt),且 release 版 Network 面板里业务字体来自 jsDelivr 而非同源。 字体表现务必用 release 构建验证(debug 的自定义 bootstrap 会被引擎覆盖)。
cd rakullapp_core
shasum -a 256 assets/fonts/*.ttf
../.codex/skills/flutter-dev/scripts/fl.sh dart format lib test
../.codex/skills/flutter-dev/scripts/fl.sh flutter analyze
../.codex/skills/flutter-dev/scripts/fl.sh flutter test
node --test web/raku_fonts.node.test.mjs
../.codex/skills/flutter-dev/scripts/fl.sh flutter build web \
  --no-wasm-dry-run --no-web-resources-cdn

Web 包体与流量规则

  • 7 个业务字体全部不进 FontManifest.json,且被 platforms: 排除出 Web 构建;首帧零业务字体字节,同源服务器物理上没有业务 ttf 可传(CanvasKit 引擎自身资源除外)。
  • Web 运行时字体流量只走公共 CDN 镜像:多源测速选路 + 逐源 failover;禁止硬编码 单一字体域名,默认三个 jsDelivr 边缘,rakuFontConfig 注入是切到公司自有 CDN 的首选运维入口。
  • 不为同一个字体文件注册多个 Flutter 字体族别名。
  • 界面字体使用可变字重文件,覆盖主题用到的 400/500/600,避免引擎对静态 Regular 做人造加粗(CJK 上尤为明显)。
  • 字体保持全量字符集、不做激进子集:动态文章与翻译可能出现任意字符,只按静态界面 文案裁剪会让阅读器重新出现缺字框。后续如需按 unicode-range 分片,接口已预留, 需同步改造 JS 与 Dart 两侧。
  • 新增完整 CJK 字体前记录 CDN 字节数、build/web/assets/assets/fonts/ (现仅 OFL 文本,应保持无 ttf)和首帧关键请求变化。
  • 界面图标由代码自绘(见图标体系),不来自图标字体, 因此不占字体包体。

验收

在浏览器 DevTools 中启用 Disable cache 和较慢网络后刷新** release 构建** (flutter build web 后静态托管 build/web;debug 构建的自定义 bootstrap 会被引擎覆盖,不代表真实表现)。HTML 启动画面应立即出现,字体下载期间状态行 显示字体百分比,Flutter 首帧后自动消失。Network 面板中业务字体必须全部来自 jsDelivr 镜像、同源零业务字体请求,也不应让业务字体走 fonts.gstatic.com (仅拉丁的引擎 Roboto 兜底除外,见上文说明)。切换四种界面语言并打开一篇含 汉字、平假名、片假名和注音的文章,确认:
  • 首帧前只请求当前语言所需的界面字体(en/ja 2 个、中文 3 个),请求域名命中排名最快的 jsDelivr 边缘,且没有任何对本站源的 .ttf 请求
  • 拉丁字母与数字使用 Inter;简体界面的汉字使用 Noto Sans SC,繁体界面使用 Noto Sans TC, 日文界面使用 Noto Sans JP;
  • 日文正文与注音使用 Noto Serif JP(明朝),中文译文/释义使用霞鹜文楷;
  • 文章库出现前没有阅读字体请求,出现后的下一帧只预取当前语言所需字体;切换语言只补载 新字体;阅读器复用进行中的请求;
  • 把最快镜像在 DevTools 中阻断(或返回 5xx)后,下载自动 failover 到下一个 jsDelivr 边缘,最终成功;三个公共镜像全部不可达时显示重试,不会白屏(如需断网兜底,可显式在 rakuFontConfig 配置一个同源/私有源);
  • 切换语言时刻意阻断字体请求:语言不切换、界面显示重试/取消模态,恢复后重试成功;
  • 后台预取成功不显示 Toast 或状态。只有阅读被阻塞时才显示首次等待文案、单调递增的字节 进度与百分比;下载结束注册字体时显示应用阶段,失败后可以重试;
  • 大标题、按钮、底部导航、分段标签等中粗文字使用真实字重,笔画清晰、不发虚 (不是对常规字重描边的人造加粗;可变字体默认实例名为 Thin/ExtraLight 属正常, 引擎按 FontWeight 沿 wght 轴插值);
  • 支持的中日文字形无缺字框、日文汉字为日本写法(不混入简体字形),切换主题或语言后字体不变。