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;
}
这样有几个好处:
- 类型安全:PrimaryToken 明确关联了 QColor 类型,编译器会检查
- 零运行时开销:Hash 是编译时常量,查找用
unordered_map - 可重构:如果 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 分为三层:
- Reference Token:Material Design 原始规范的定义,如
md.primary - System Token:内部系统级 Token,如
sys.color.primary - Component Token:组件级 Token,如
button.filled.container
这种分层让系统更加灵活:组件可以定义自己的 Token,而系统 Token 可以被多个组件复用。
总结¶
Token 系统是一个"编译时类型安全 + 运行时灵活查找"的混合方案。StaticToken 提供零开销的编译时检查,DynamicToken 提供运行时灵活性,两者共享同一个 TokenRegistry 存储。
有了 Token 系统,我们就可以规范地访问主题数据了。但 Token 只是"标识符",实际的值怎么来?如何从一个种子颜色生成完整的 Material 主题?
接下来,我们进入颜色方案的具体实现。
相关文档
- 主题系统架构设计——主题系统的整体设计
- 颜色方案实现——从种子颜色到完整主题
- HCT 色彩空间实战——Layer 1 的颜色数学基础