@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()从本地注册,零运行时 网络访问、可离线使用。
当前策略
字体分为“界面字体”和“阅读字体”两组,均不进入 pubspec 的fonts: 段
(因此不进入 FontManifest.json、不阻塞首帧),而是以普通 assets 发布并在
运行时通过 FontLoader 注册:
- 界面字体(Web 首帧前):
main()在runApp前按当前 Locale 调用UiFontLoader.ensureFor(),只下载该语言需要的界面字体。 - 界面字体(原生):
main()调用UiFontLoader.registerAllBundled(), 从本地 bundle 一次性注册全部 4 个界面字体,不产生任何网络请求。 - 阅读字体(按需):文章库实际呈现后的下一帧按当前语言静默预取,进入阅读器 或语法库时由加载门兜底等待。预取与加载门共享同一并发任务及成功缓存。
- 切换语言:语言切换器先
ensureFor()新语言的界面字体,成功后才提交 Locale;失败则保留原语言并弹出“重试 / 取消”,避免出现缺字框(tofu)。
web/raku_fonts.js(零依赖、在 flutter_bootstrap.js
之前同步加载)负责选源与下载。默认源是 jsDelivr 的三个公共镜像边缘,
开箱即用、无需任何运维配置:
- Flutter 引擎启动的同时,对每个源并发发起 16 KB Range 探测 (探测文件为 Inter),按真实耗时排名,国内用户和海外用户各自命中最快的 可达边缘;探测失败或超时的源排在最后。
fetchFont(family)按排名逐源尝试,网络错误、CORS 拒绝或非 2xx 状态码 自动 failover 到下一个镜像。- 源有两类:
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 JP | Noto Serif JP |
| 简体中文 | Inter、Noto Sans SC、Noto Sans JP | Noto Serif JP、LXGW WenKai(简) |
| 繁體中文 | Inter、Noto Sans TC、Noto Sans JP | Noto 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.js 的 FONT_FILES 管 Web 的仓库
token 与字节数;Dart 侧 ReadingFontAsset 用 nativeBytes / webBytes
两个字段登记,expectedBytes 按 kIsWeb 取当前平台的值(仅用于服务端不返回
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.ttf | Lite 版 LxgwWenKai-Lite,13,872,424 B;生僻字由 Noto 回退兜底 |
| 位置 | 职责 |
|---|---|
rakullapp_core/assets/fonts/ | 字体二进制、来源说明、SHA-256 与 OFL 授权文本。仅供原生 bundle;Web 构建后 assets/assets/fonts/ 只剩 4 个 OFL 文本,无任何 ttf |
rakullapp_core/pubspec.yaml | 7 个 ttf 用 - path: + platforms: [android, ios, macos, windows, linux] 限定为仅原生平台(Web 被排除);4 个 OFL 文本保留全平台(Web 的许可页要读);不存在 fonts: 段,任何字体都不进首帧 FontManifest |
web/raku_fonts.js | Web 选源与下载:默认三个 jsDelivr 公共镜像、多源并发 Range 探测、按耗时排名、逐源 failover、进度聚合、同 family 并发去重;暴露 window.rakuFonts.fetchFont() 给 Dart |
web/index.html | 在 flutter_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.dart | family→文件名映射、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/仓库镜像:baseUrl:一个平铺目录,取字时拼接FONT_FILES的file文件名。适合 直接把字体对象存进自家对象存储 / CDN:
REPLACE-ME)、重复源会在运行时自动丢弃;不会再自动
追加同源目录(同源里本就没有 ttf)。国内、海外边缘按需列出,只存在一个就只
列一个;列出的源完全替换默认的三个公共镜像。
baseUrl 类源的 CDN 侧要求:
- 以同名扁平结构提供每个
FONT_FILES.file(即<baseUrl>/Inter-Variable.ttf、<baseUrl>/NotoSansJP-Variable.ttf等, 文件名必须与FONT_FILES的file完全一致); - 注意 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 直接透传、不做边缘压缩(进度按原始字节统计)。
canvaskit/、引擎字体回退目录
assets/fallback_fonts/)仍随构建目录同源托管。
引擎拉丁兜底 Roboto:当前 release 观测到 CanvasKit 在尚未命中业务字体时,
可能向
fonts.gstatic.com 请求一个仅含拉丁字符的 Roboto woff2 作为引擎
兜底;它不承载任何日文/中文字形,业务字体全部来自 jsDelivr。如需做到
完全无第三方请求,可在 assets/fallback_fonts/ 自托管一份小体积 Roboto
(引擎通过 fontFallbackBaseUrl 读取)——该项与业务字体 CDN 化相互独立,
属可选的后续加固。添加或替换字体
- 只从字体项目的官方发行渠道取得文件,并确认应用分发方式符合字体授权。
- 把字体放入
rakullapp_core/assets/fonts/,同时保存完整授权文本。 - 在该目录的
README.md记录版本、官方来源 URL 和 SHA-256。 - 在
AppFonts添加族常量,并在_fontFiles登记 family→原生文件名; 同步在web/raku_fonts.js的FONT_FILES登记file(baseUrl平铺 文件名)、token(mirror仓库路径)与size(Dart 与 JS 的 family 键名必须严格一致)。阅读字体还要更新ReadingFontAsset的nativeBytes/webBytes(无 Content-Length 时驱动进度条);若 Web 与 原生用不同文件,两个字节数分别填。 - 在
AppFonts.uiFontFamiliesFor/ReadingFontLoader.assetsFor更新 Locale 矩阵。日文正文专用字体只需修改japaneseText,不要逐个修改阅读器组件。 - 不要把任何字体加回 pubspec 的
fonts:段,否则 Flutter 会在首帧前下载。 新 ttf 以- path:+platforms: [android, ios, macos, windows, linux]列入assets(排除 web);授权文本(OFL)则不加platforms、保留 全平台。界面字体优先使用可变字重文件,且不要给可变资产写静态weight, 否则引擎无法按轴插值。 - 若增加授权文件,在
AppFonts.registerLicenses()中注册,确保 Flutter License UI 能展示它。 - 若使用默认公共镜像,确认
FONT_FILES.token指向的仓库对象可公开访问;若用 自有baseUrl,把对应字节按file同名扁平结构上传到所有源后再发布。 - 运行格式化、静态分析、测试、Node 选源自测与 release Web 构建;确认
FontManifest.json不含任何应用字体、raku_fonts.js被index.html引用、build/web下不存在任何业务 ttf(assets/assets/fonts/仅 OFL*.txt),且 release 版 Network 面板里业务字体来自 jsDelivr 而非同源。 字体表现务必用 release 构建验证(debug 的自定义 bootstrap 会被引擎覆盖)。
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 轴插值);
- 支持的中日文字形无缺字框、日文汉字为日本写法(不混入简体字形),切换主题或语言后字体不变。