Skip to content

构建应用

多语言 ​

Tessera 应用把面向用户的文案放在 Fluent 消息目录里,每种语言一组文件,在显示文字的地方查找消息。当前语言是应用入口持有的响应式状态,切换语言时,读取了翻译文本的组件会随之重建,应用继续运行。

在组件中查找消息是这样的:

rust
use tessera_components::text::text;
use tessera_ui::tessera;
use crate::res;

#[tessera]
fn page_heading() {
    text(res::I18N.text("notes-title"));
}

res::I18N 来自生成的资源模块,本页其余部分介绍如何生成并注册它。和其他 Material 组件一样,这个示例要放在应用的 material_theme() 祖先下。

编写消息目录 ​

在 tessera-config.toml 中添加 [i18n] 表:

toml
[i18n]
dir = "locales"
fallback = "en"

dir 相对于包根目录,默认为 locales。fallback 必须填写:消息缺失时使用这种语言,它同时也是生成目录的默认语言,所以对应的消息目录必须存在。

在这个目录下,每种语言一个文件夹,按功能拆分消息:

text
locales/
├── en/
│   ├── notes.ftl
│   └── settings.ftl
└── zh-CN/
    ├── notes.ftl
    └── settings.ftl

同一语言文件夹里的所有文件合成一份消息目录。文件名只用来组织源文件,不属于消息 ID。因此同一语言的各个文件之间 ID 不能重复,各语言的翻译也要使用相同的 ID 和参数名。

例如,locales/en/notes.ftl 的内容是:

ftl
notes-title = Notes
notes-search = Search notes
notes-greeting = Hello, { $name }
notes-selected = { $count ->
    [one] One note selected
   *[other] { $count } notes selected
}

locales/zh-CN/notes.ftl 的内容是:

ftl
notes-title = 笔记
notes-search = 搜索笔记
notes-greeting = 你好,{ $name }
notes-selected = 已选择 { $count } 条笔记

给消息起稳定、表达含义的 ID,例如 notes-title。每个句子完整地写在一条 Fluent 消息里,译者才能调整参数位置,选用自己语言需要的复数和语法形式——上面的中文文件只用一种形式,因为中文名词没有单复数变化。

生成资源模块 ​

应用需要把 tessera-i18n 加为普通依赖,把 tessera-build 加为构建依赖。脚手架的 build.rs 已经调用了资源构建:

rust
fn main() {
    tessera_build::build().expect("Failed to build Tessera resources");
}

构建过程会读取配置、校验消息目录,并把 Fluent 源文件嵌入生成的绑定。消息文件会作为构建输入被跟踪,修改 .ftl 文件会触发重新构建,消息目录随可执行文件一起发布。

src/res.rs 把这些绑定公开给 crate 的其他部分:

rust
#[allow(missing_docs, clippy::all)]
mod generated {
    include!(concat!(env!("OUT_DIR"), "/tessera_assets.rs"));
}

pub use generated::*;

生成的文件里还有应用的其他资源,每次构建都会重写,修改请在消息目录和 tessera-config.toml 中进行。其余资源见资源,这些文件在项目中的位置见项目结构。

注册消息目录 ​

在应用入口上注册 res::i18n(),和 ComponentsPackage 放在一起:

rust
mod app;
pub mod res;

use tessera_ui::EntryPoint;

#[tessera_ui::entry]
pub fn run() -> EntryPoint {
    EntryPoint::new(app::app)
        .config(res::application())
        .package(tessera_components::ComponentsPackage)
        .package(res::i18n())
}

脚手架为各目标平台添加的配置也要保留。语言状态由消息目录包持有。没有注册时,res::I18N 仍能按默认语言解析消息,但 I18n::current() 拿不到控制器,会直接 panic。

查找消息 ​

text 解析不带参数的消息。format 接受命名参数,形式是由 FluentValue 键值对组成的数组或其他迭代器:

rust
use tessera_components::{column::column, text::text, text_field::text_field};
use tessera_i18n::FluentValue;
use tessera_ui::tessera;
use crate::res;

#[tessera]
fn notes_summary(count: u32) {
    column().children(move || {
        let greeting = res::I18N.format(
            "notes-greeting",
            [("name", FluentValue::from("Ada"))],
        );
        text(greeting);
        text(res::I18N.format(
            "notes-selected",
            [("count", FluentValue::from(count))],
        ));
        text_field().label(res::I18N.text("notes-search"));
    });
}

需要 Fluent 选择复数形式时,用数值形式的 FluentValue 传入数字。组件接收的是最终字符串,所以先解析好消息(包括插值),再传给组件。

查找消息会读取入口的语言状态,所以应该放在组件函数体里:在构建期间解析,语言变化时读取它的组件才会准确地重建。状态里保存消息 ID 或底层数据,在函数体里解析文本;存进 remember 的翻译结果在切换语言后仍是旧语言。

用属性把相关文案放在一起 ​

Fluent 消息可以带属性,查找时写成 消息ID.属性名。用它把控件的名称和无障碍描述并排放在目录里,很清楚:

ftl
notes-delete = Delete note
    .description = Moves the note to the trash
rust
use tessera_components::{icon_button::icon_button, material_icons::filled};
use tessera_ui::tessera;
use crate::res;

#[tessera]
fn delete_note_button() {
    icon_button()
        .icon(filled::DELETE_SVG)
        .accessibility_label(res::I18N.text("notes-delete"))
        .accessibility_description(res::I18N.text("notes-delete.description"))
        .on_click(|| {});
}

无障碍标签同样是给用户看的文字,要和可见文案用同一份目录翻译。见无障碍。

切换语言 ​

在 UI 动作中取得当前入口的控制器,设置新语言:

rust
use tessera_i18n::{I18n, langid};

I18n::current().set_locale(langid!("zh-CN"));

I18n::current().locale() 返回请求的语言,个别消息仍可能来自回退语言。locales() 列出应用目录提供的语言,适合用来做语言选择器。生成的包以配置的回退语言启动;想用其他初始语言,改为注册 res::i18n().locale(langid!("zh-CN"))。

set_locale 修改的是入口持有的内存状态。如果用户的选择要在重启后保留,需要你自己保存,并在启动时应用。路由键、列表项身份和用户输入的内容应基于稳定的值,与翻译文本分开,这样它们在每种语言下都指向同一个对象。

回退与缺失消息 ​

生成的加载器逐条解析消息:先尝试与请求语言匹配的目录,再尝试配置的回退语言。所以翻译不完整时,缺失的消息会显示回退文本,而请求的语言保持不变。请保持回退目录完整,并检查每种随应用发布的语言。

所有目录都解析不了某条消息时,text 和 format 返回它的 ID。缺少参数或其他格式化错误也会得到同样的结果。界面上出现 ID 时,检查这条消息和它的参数。

翻译组件内置文案 ​

自带文字的组件(例如文本输入框的复制、粘贴操作)使用以 md3- 开头的消息 ID。组件目录提供英文和简体中文(zh-CN),并跟随入口的语言。应用目录中的消息优先于组件消息,应用的回退消息也是如此。

要修改某条内置文案,在应用目录中添加同一个 ID,例如在 locales/en/controls.ftl 中:

ftl
md3-copy = Copy text
md3-paste = Paste text

其他语言也要补上对应的翻译。覆盖带参数的消息时,保留原有的参数名和含义。ID 和参数以 Tessera 仓库中 crates/tessera-components/locales/ 下的目录为准,请从那里查找。

在测试中检查每种语言 ​

无窗口测试可以启动真实的应用入口,切换语言,再检查应用发布的内容。它注册的包与应用完全相同,所以既能发现缺失的消息,也能发现漏掉的注册。下面的测试假设应用显示了上一节的删除按钮:

rust
use std::time::Duration;

use tessera_i18n::{I18n, langid};
use tessera_ui::{HeadlessConfig, accesskit::Role};

#[test]
fn toolbar_is_translated() {
    let mut renderer = crate::run()
        .into_headless(HeadlessConfig {
            width: 400,
            height: 300,
            scale_factor: 1.0,
            frame_time: Duration::from_millis(16),
            ..Default::default()
        })
        .expect("headless renderer");

    renderer.run(|| I18n::current().set_locale(langid!("zh-CN")));
    renderer.render_frames(2);

    let tree = renderer.inspect_accessibility().expect("accessibility tree");
    assert!(tree.find("删除笔记", Role::Button).is_some());
}

这需要启用 headless 和 testing 特性,并且需要 GPU 适配器,配置方法见测试。

消息目录和语言控制器见本地化 API 参考,入口包见应用入口与窗口。