跳转至

Token 系统设计——字符串字面量的编译时魔法

在上一篇文章里,我们讲了主题系统的整体架构:ICFTheme 接口定义了主题包含什么,ThemeFactory 负责创建主题,ThemeManager 管理多个主题并处理切换。

但有一个问题我们没深入聊:控件怎么访问主题里的数据?直接用字符串查询吗?比如 theme->getColor("md.primary")

这方案能用,但有几个问题:字符串拼接容易出错、编译期无法检查、运行时查找有性能开销。Material Design 3 有 26 个标准颜色角色,还有各种字体、圆角、动画参数,如果全靠字符串查询,代码里会到处是"魔法字符串"。

于是我们设计了 Token 系统。

Token 系统的动机

Token 系统的核心思想是:把"字符串标识符"和"编译时类型安全"结合起来。

理想情况下,我们希望这样写:

// 定义一个 token
using PrimaryToken = StaticToken<QColor, "md.primary"_hash>;

// 注册 token
TokenRegistry::get().register_token<PrimaryToken>(QColor("#6200EE"));

// 使用 token
auto result = PrimaryToken::get();
if (result) {
    QColor color = *result;
}

这样有几个好处:

  1. 类型安全:PrimaryToken 明确关联了 QColor 类型,编译器会检查
  2. 零运行时开销:Hash 是编译时常量,查找用 unordered_map
  3. 可重构:如果 token 名字改了,编译器会报错(虽然要手动改 hash)

StaticToken:编译时类型安全

StaticToken 是一个模板类,它的两个模板参数分别是值类型 T 和名字的哈希值 Hash

template <typename T, uint64_t Hash> class StaticToken {
public:
    using value_type = T;
    static constexpr uint64_t hash_value = Hash;

    // 删除所有构造函数——这只是一个类型封装,不能实例化
    StaticToken() = delete;
    StaticToken(const StaticToken&) = delete;

    // 静态方法获取值
    static cf::expected<T*, TokenError> get();
};

注意这里的设计技巧:StaticToken 的所有构造函数都被删除了。这意味着你不能创建 StaticToken 的实例——它只是一个"类型",用来承载编译时信息。

实际使用时,你用的是 StaticToken<QColor, Hash>::get() 这个静态方法。

constexpr 哈希

为了在编译时计算字符串的哈希,我们用了 FNV-1a 64 位哈希算法,它可以写成 constexpr 函数:

constexpr uint64_t fnv1a64(std::string_view str) {
    uint64_t hash = 14695981039346656037ULL;
    for (char c : str) {
        hash ^= static_cast<uint64_t>(c);
        hash *= 1099511628211ULL;
    }
    return hash;
}

// 用户定义字面量(可选)
constexpr uint64_t operator""_hash(const char* str, size_t len) {
    return fnv1a64(std::string_view(str, len));
}

这样你就可以在编译时计算字符串的哈希:

constexpr uint64_t PRIMARY_HASH = fnv1a64("md.primary");  // 编译时常量
using PrimaryToken = StaticToken<QColor, PRIMARY_HASH>;

TokenRegistry:运行时存储

StaticToken 只是一个"类型",真正的数据存在 TokenRegistry 里:

class TokenRegistry {
public:
    static TokenRegistry& get();

    // 注册静态 token
    template <typename TokenToken, typename... Args>
    Result<void> register_token(Args&&... args);

    // 获取静态 token
    template <typename TokenToken>
    Result<typename TokenToken::value_type*> get();

    // 动态 token(运行时字符串)
    template <typename T>
    Result<void> register_dynamic(std::string_view name, Args&&... args);

    template <typename T>
    Result<T*> get_dynamic(std::string_view name);

private:
    mutable std::shared_mutex registry_mutex_;
    std::unordered_map<uint64_t, detail::TokenSlot> slot_map_;
};

TokenRegistry 是一个单例,内部用 unordered_map<uint64_t, TokenSlot> 存储数据,键是哈希值。

类型擦除与 std::any

TokenSlot 使用 std::any 进行类型擦除:

struct TokenSlot {
    std::unique_ptr<std::any> data;  // 类型擦除的值
    const std::type_info* type_info; // 用于类型检查
    std::string name;                // 调试用
};

注册时,我们创建一个 std::any 存储 T 类型的值,同时保存 typeid(T) 用于后续的类型检查。

获取时,我们先检查 type_info 是否匹配,然后用 std::any_cast 提取值:

template <typename TokenToken>
auto TokenRegistry::get() -> Result<typename TokenToken::value_type*> {
    using T = typename TokenToken::value_type;
    constexpr uint64_t hash = TokenToken::hash_value;

    std::shared_lock<std::shared_mutex> lock(registry_mutex_);

    const detail::TokenSlot* slot = find_slot_locked(hash);
    if (!slot) {
        return cf::unexpected(TokenError{TokenError::Kind::NotFound, "..."});
    }

    if (slot->type_info != &typeid(T)) {
        return cf::unexpected(TokenError{TokenError::Kind::TypeMismatch, "..."});
    }

    return std::any_cast<T>(slot->data.get());
}

线程安全设计

TokenRegistry 使用 std::shared_mutex 来支持多读单写:

// 读操作(get):共享锁
std::shared_lock<std::shared_mutex> lock(registry_mutex_);

// 写操作(register_token):独占锁
std::unique_lock<std::shared_mutex> lock(registry_mutex_);

这个设计适合"读多写少"的场景——token 注册通常发生在初始化阶段,之后大部分时候都是读取。

shared_mutex 的好处是:多个读操作可以并发进行,只有在写操作时才需要独占访问。

constexpr 字面量定义

Material Design 3 的 26 个颜色 Token 被定义为 constexpr 字面量:

namespace cf::ui::core::token::literals {

inline constexpr const char PRIMARY[] = "md.primary";
inline constexpr const char ON_PRIMARY[] = "md.onPrimary";
inline constexpr const char PRIMARY_CONTAINER[] = "md.primaryContainer";
// ... 共 26 个

inline constexpr const char* const ALL_TOKENS[] = {
    PRIMARY, ON_PRIMARY, PRIMARY_CONTAINER, ON_PRIMARY_CONTAINER,
    // ...
};
inline constexpr size_t TOKEN_COUNT = 26;

} // namespace cf::ui::core::token::literals

这些 constexpr 字面量可以在编译时使用,而且避免了字符串字面量的重复。

ICFColorScheme 的 Token 访问

ICFColorScheme 接口的 queryExpectedColor 方法就是用 Token 系统实现的:

struct ICFColorScheme {
    virtual QColor& queryExpectedColor(const char* name) = 0;

    QColor queryColor(const char* name) const {
        return const_cast<ICFColorScheme*>(this)->queryExpectedColor(name);
    }
};

MaterialColorScheme 的实现内部会用 TokenRegistry 来查找颜色。虽然这里还是用了字符串参数,但内部实现可以复用 Token 系统,保持一致性。

DynamicToken:运行时类型擦除

除了 StaticToken,我们还支持 DynamicToken——完全运行时的 token:

// 注册
TokenRegistry::get().register_dynamic<QColor>("custom.color", QColor("#FF0000"));

// 使用
auto result = TokenRegistry::get().get_dynamic<QColor>("custom.color");
if (result) {
    QColor color = *result;
}

DynamicToken 用的是运行时字符串查找,没有编译时检查,但更加灵活。适合插件系统、用户自定义主题等场景。

三层 Token 结构

在完整的 Material Design 3 体系中,Token 分为三层:

  1. Reference Token:Material Design 原始规范的定义,如 md.primary
  2. System Token:内部系统级 Token,如 sys.color.primary
  3. Component Token:组件级 Token,如 button.filled.container

这种分层让系统更加灵活:组件可以定义自己的 Token,而系统 Token 可以被多个组件复用。

总结

Token 系统是一个"编译时类型安全 + 运行时灵活查找"的混合方案。StaticToken 提供零开销的编译时检查,DynamicToken 提供运行时灵活性,两者共享同一个 TokenRegistry 存储。

有了 Token 系统,我们就可以规范地访问主题数据了。但 Token 只是"标识符",实际的值怎么来?如何从一个种子颜色生成完整的 Material 主题?

接下来,我们进入颜色方案的具体实现。


相关文档