首页
/ Dioxus Manganis 资源系统架构:编译期 asset!() 宏、常量序列化与二进制符号驱动的资源打包管线

Dioxus Manganis 资源系统架构:编译期 asset!() 宏、常量序列化与二进制符号驱动的资源打包管线

2026-09-05 22:53:59作者:郁楠烈Hubert

本文解析 Dioxus 中 Manganis 资源系统的完整架构:从 asset!() 宏的编译期展开、基于 const_serialize 的编译期二进制序列化,到 dx CLI 通过二进制符号表扫描完成资源优化与回写补丁的全流程。读完后你将理解 Dioxus 如何在无需清单文件的情况下实现类型安全的资源引用、内容哈希缓存失效(cache-busting)与跨 Web/桌面/移动端的一致资源解析。

一、总体架构:编译期嵌入,构建期回写

Manganis 的核心设计是把"资源元数据"直接嵌入到 Rust 二进制中,而不是依赖外部清单文件(manifest)。其工作流可以概括为三个阶段:

  1. 编译期asset!() 过程宏解析资源路径,将 BundledAsset(含绝对源路径、目标路径占位符、处理选项)序列化为 CBOR 字节,写入以 __ASSETS__{hash} 命名的导出符号(link section);
  2. 构建期:dx CLI 解析编译产物的符号表,反序列化出每个资源条目,对资源文件做优化处理(图片转码/缩放、CSS/JS 压缩),然后把带内容哈希的最终路径回写进二进制对应符号的字节位置;
  3. 运行期Asset::resolve() 根据"开发模式"还是"已打包应用"返回不同路径——开发时直接用源码绝对路径,打包后走 {base_path}/assets/{hashed-filename}

这一设计使资源注册表与二进制天然同步:多 crate 应用中每个 crate 的符号会自动合并,无需额外的清单聚合步骤。

asset!("/assets/image.png", AssetOptions::image())
    ↓
1. Path Resolution
   - 相对于 CARGO_MANIFEST_DIR 解析
   - 校验路径存在且位于当前 crate 内

2. File Hashing
   - 由 span + options + 路径 生成 DefaultHasher
   - 产生 16 位十六进制哈希

3. Generate Link Section
   - 生成 __ASSETS__{hash} 符号
   - 生成 __MANGANIS__{hash}(兼容旧版 CLI)
   - 均使用 const_serialize 的 CBOR 序列化

4. Code Generation
   - 生成带 PLACEHOLDER_HASH 的 BundledAsset 常量
   - 生成带函数指针的 Asset 结构体
   - 用 volatile 读取防止被优化

二、asset!() 宏的编译期展开

2.1 宏输入与路径解析

宏的输入分两部分:资源路径字符串,以及可选的 AssetOptions 构造表达式。以窄化资源类型为例:

asset!(
    "/assets/myfile.png",
    AssetOptions::image()
        .format(ImageFormat::Jpg)
        .size(512, 512)
)

路径解析在 AssetParser 的 parse 实现 中完成:PathResolver::new(&src, &path_expr.span()).resolve() 将路径相对于 CARGO_MANIFEST_DIR 解析为绝对路径,同时保留原始 token 的 span 以便报错定位。若路径不存在,宏会退化为 compile_error! 或生成 Option::<Asset>::Noneasset_option! 场景),见 expand_option_tokens。注意:相对路径(如 ./assets/xxx.png)已被标记为弃用写法,create_bundled_asset_relative 会给出明确提示,建议统一使用 /assets/myfile.png 形式。

2.2 INPUT_HASH 的计算

宏为每个资源调用点计算一个 16 位十六进制哈希(即文档中的 INPUT_HASH),用于唯一标识该资源对应的符号。从 expand_asset_tokens 的源码可以看到其确切构成:

let mut hash = DefaultHasher::new();
format!("{:?}", self.options.span()).hash(&mut hash);
format!("{:?}", self.options.to_string()).hash(&mut hash);
asset_string.hash(&mut hash);
let asset_hash = format!("{:016x}", hash.finish());

span 调试字符串 + options 源码文本 + 资源路径 三者的 DefaultHasher 摘要。由于包含了 span,同一资源在文件中的不同调用点(甚至同一行修改后)会生成不同符号,这保证了热更新与增量构建的精确性。

2.3 Link Section 的生成

generate_link_section 负责把序列化后的 BundledAsset 挂到一个带 export_name 的静态数组上,见 linker.rs

static __LINK_SECTION: &'static [u8] = {
    const __BUFFER: #buffer_type = #serialize_fn(&#item);
    const __BYTES: &[u8] = __BUFFER.as_ref();
    const __LEN: usize = __BYTES.len();

    #[unsafe(export_name = #export_name)]   // "__ASSETS__{16位哈希}"
    static __LINK_SECTION: [u8; __LEN] = #copy_bytes_fn(__BYTES);
    &__LINK_SECTION
};

几个关键细节:

  • 固定宽度:序列化的目标缓冲区是 ConstVec<u8, 4096>serialize_asset 会把数据补零到满 4096 字节。CLI 侧的 MANGANIS_SECTION_SIZE 与之对齐——"每条记录定宽 4096 字节,因此仅凭符号偏移就能定位条目",这让二进制补丁阶段无需解析长度前缀。
  • #[used] 强制保留:即使 Asset 常量在代码中未被引用,链接器也不会剥离该符号,保证资源一定进入二进制(文档强调的 "force the asset to be included even if unused")。

2.4 最终生成的代码形态

宏展开后的完整 token 流见 to_tokens 生成的 quote!

{
    // 供 CLI 拷贝资源使用的源路径
    const __ASSET_SOURCE_PATH: &'static str = "/abs/path/to/assets/image.png";
    // 供 CLI 了解如何处理的选项
    const __ASSET_OPTIONS: manganis::AssetOptions = /* options */;
    // 输入 token 哈希,唯一标识 link section
    const __ASSET_HASH: &'static str = "16位十六进制";
    // 供 crate 使用的 BundledAsset,bundled_path 暂为 PLACEHOLDER_HASH
    const __ASSET: manganis::BundledAsset =
        manganis::macro_helpers::create_bundled_asset(__ASSET_SOURCE_PATH, __ASSET_OPTIONS);

    // 上文生成的 __LINK_SECTION

    manganis::Asset::new(
        || unsafe { std::ptr::read_volatile(&__LINK_SECTION) },
    )
}

这里 bundled_path 字段写入的是占位符 BundledAsset::PLACEHOLDER_HASH(一段提示文案),真正的目标路径要等 CLI 构建期回写。若应用在没有经过 dx 链接流程的情况下直接运行,运行时读取该占位符即可感知"未被打包"。

三、Asset 结构体与 volatile 读取的正确性

运行时使用的 Asset 类型只持有一个函数指针,而非静态数据指针:

pub struct Asset {
    /// 通过函数间接读取静态 link section,迫使编译器
    /// 在运行时读取数据而非在编译期常量折叠
    bundled: fn() -> &'static [u8],
}

源码注释解释了为什么必须用函数包裹:因为 link section 的字节会在编译之后被 CLI 改写(回写哈希路径),如果编译器能在编译期折叠该读取,就会拿到占位符数据,且热更新引擎也无法对其做偏移修正。

Asset::bundled() 的实现在 bundled() 中,逐字节 std::ptr::read_volatile 读取后调用 deserialize_const!(BundledAsset, ...) 反序列化:

let byte = unsafe { std::ptr::read_volatile(ptr.add(byte)) };

若指针为空(符号被剥离或未经 dx 构建),会 panic 并提示 "Make sure you are compiling dx as the linker"。这就是文档"Key Architectural Patterns"中 Volatile Reads for Correctness 条目的源码级依据。

四、Link Section 双格式与版本兼容

为兼容不同版本的 CLI,文档描述了两种序列化/符号格式:

格式 符号前缀 序列化方式 适用
旧版(Legacy) __MANGANIS__{hash} const_serialize_07 dx 0.7.0 ~ 0.7.1
新版(Current) __ASSETS__{hash} const_serialize(CBOR) dx ≥ 0.7.2

两者的数据都以 PLACEHOLDER_HASH 哨兵开头,构建时被 CLI 替换为真实内容。当前仓库源码中,符号生成统一走 __ASSETS__ 前缀(见 generate_link_section 的 export_name),旧格式属于文档记载的向后兼容设计;CLI 的迁移策略是"先尝试新格式反序列化,失败或发现仍是 PLACEHOLDER_HASH 时回退旧格式"。

值得注意的演进点:当前 CLI 反序列化时不再只认裸 BundledAsset,而是先尝试更通用的 SymbolData 包装格式(FFI/widget 宏的元数据也复用同一套 4096 字节符号嵌入机制),失败后再回退到 BundledAsset,两种尾部补零均被接受。

五、资产类型体系与选项(AssetOptions)

5.1 类型层级

Manganis 按资源类型提供细化的选项集,完整层级为:

AssetOptions
├── ImageAssetOptions
│   ├── format: ImageFormat (Png, Jpg, Webp, Avif)
│   ├── size: ImageSize (Manual | Automatic)
│   └── preload: bool
│
├── CssAssetOptions
│   ├── minify: bool (默认 true)
│   ├── preload: bool
│   └── static_head: bool
│
├── JsAssetOptions
│   ├── minify: bool (默认 true)
│   ├── preload: bool
│   └── static_head: bool
│
├── CssModuleAssetOptions
│   ├── minify: bool
│   └── preload: bool
│
├── FolderAssetOptions
│   └── (无自定义选项)
│
└── Unknown(通用二进制)

在源码中,这体现为 AssetOptions 的两个字段 + 变体枚举:

pub struct AssetOptions {
    pub(crate) add_hash: bool,   // 是否在资源路径追加哈希(默认 true)
    pub(crate) variant: AssetVariant,
}

#[repr(C, u8)]          // 常量序列化要求明确的内存布局
pub enum AssetVariant {
    Image(ImageAssetOptions),
    Folder(FolderAssetOptions),
    Css(CssAssetOptions),
    CssModule(CssModuleAssetOptions),
    Js(JsAssetOptions),
    Unknown,
}

两个要点:

  1. #[repr(C, u8)] 是硬性要求——const_serialize 的枚举布局依赖显式 repr(见第六节的 Layout 约束),文档中 "repr(C, u8) required" 即指此处。
  2. 所有选项类型均可在常量期序列化SerializeConst derive),因此能整体嵌入 link section;同时它们也派生 serde 的 Serialize/Deserialize,供 CLI 侧使用。

5.2 Builder 与缓存失效开关

选项通过 const 泛型 builder 构造,未传选项时宏会填充 AssetOptions::builder()(见 asset.rs 第 100-104 行)。核心开关是 with_hash_suffix

static ASSET: Asset = asset!(
    "/assets/style.css",
    manganis::AssetOptions::builder()
        .with_hash_suffix(false)
);

add_hash 默认为 true(builder new() 中初始化)。关闭哈希后路径不带内容指纹,源码文档同时警告:若在 Rust 代码之外引用这类固定路径资源,必须显式加 #[used] 保证符号不被剥离:

#[used]
static ASSET: manganis::Asset = manganis::asset!(
    "/assets/style.css",
    manganis::AssetOptions::builder().with_hash_suffix(false)
);

AssetOptions::extension() 还会按变体返回输出扩展名(如 Image 取决于所选格式,CSS/CSSModule 为 css,JS 为 js,Folder/Unknown 为 None),供 CLI 生成输出文件名。

5.3 CSS Module 集成

css_module! 宏把 CSS 文件的作用域类名编译为常量字段:

css_module!(Styles = "/my.module.css", AssetOptions::css_module());

// 展开后等价于:
struct Styles {}
impl Styles {
    pub const header: &str = "abc[hash]";  // 作用域化的唯一类名
    pub const button: &str = "def[hash]";
}

其 CSS 标识符收集规则(见文档与 css_module_parser):

  • 扫描样式中的 .className#idName 模式;
  • 转换为 snake_case 作为结构体字段名;
  • 每个字段值是 ConstStr,解引用时自动注入对应 stylesheet,因此在 rsx 中写 class: Styles::header 既拿到作用域类名,又保证样式被引用。

仓库内可直接参考 examples/03-assets-styling/css_modules.rsstylesheet.rsdynamic_assets.rs 等示例。

六、常量序列化系统(const_serialize)

这是整个资源系统能"编译期生成二进制数据"的底座。

6.1 CBOR 格式(RFC 8949 子集)

const_serialize 的线上格式是 CBOR 子集,支持的主类型(Major Types):

主类型 编号
无符号整数 0
负整数 1
字节串 2
字符串 3
数组 4
映射 5

不支持 Tag(6)与浮点(7)——这与"只序列化资源元数据"的场景匹配。serialize_const 的文档示例 直观展示了编码结果:

serialize_const(&Struct { a: 0x11111111, b: 0x22, c: 0x33333333 }, buffer)
// → &[0xa3, 0x61, 0x61, 0x1a, 0x11, 0x11, 0x11, 0x11,
//     0x61, 0x62, 0x18, 0x22, 0x61, 0x63, 0x1a, 0x33, 0x33, 0x33, 0x33]
//      ↑ map(3)  ↑ 键"a"(len1) ↑ u32 头部(0x1a+4字节) ...

6.2 基于内存布局的常量期拷贝

核心抽象是 Layout 枚举与 SerializeConst trait

#[derive(Debug, Copy, Clone)]
pub enum Layout {
    Enum(EnumLayout),       // 要求 repr(C, u8)
    Struct(StructLayout),   // 各字段偏移
    Array(ArrayLayout),     // 定长
    Primitive(PrimitiveLayout),
    List(ListLayout),       // 变长
}

/// # Safety
/// 布局必须准确描述类型的内存布局
pub unsafe trait SerializeConst: Sized {
    const MEMORY_LAYOUT: Layout;
    const _ASSERT: () =
        assert!(Self::MEMORY_LAYOUT.size() == std::mem::size_of::<Self>());
}

_ASSERT 关联常量在类型定义时静态断言"声明的布局尺寸必须等于类型实际尺寸",从编译期杜绝布局漂移。序列化流程即文档所述四步:

  1. MEMORY_LAYOUT 计算总尺寸;
  2. 按布局从源地址拷贝字节(serialize_const_ptr 按 Layout 分派);
  3. 应用转换(如整数按小端字节序重排后写入 CBOR 编码);
  4. 追加到 ConstVec 缓冲区。

全程只使用 const fn,无堆分配,因此可以嵌入宏生成的 const 上下文。

6.3 ConstStr:定长字符串

资源路径与 CSS 标识符在常量期以 ConstStr 表示:

pub struct ConstStr {
    bytes: [MaybeUninit<u8>; 256],  // 固定 256 字节缓冲
    len: u32,
}

256 字节上限对应 str.rs 的 MAX_STR_SIZE,序列化时以 List 布局输出长度与内容。由于绝对路径可能较长,实际使用中路径一般保持在合理长度内;超限会因布局尺寸断言而编译失败。

6.4 ConstVec:定容常量缓冲区

ConstVec<u8, 4096> 是宏生成的中间缓冲(见 serialize_to_const_with_max_padded):先序列化出变长 CBOR 数据,再补零至 4096。CLI 侧回写时同样维持定宽,见 serialize_bundled_asset 中的 data.resize(MANGANIS_SECTION_SIZE, 0)。定宽化是"按符号偏移直接覆写"这一补丁策略的前提。

七、构建期资源管线(dx CLI)

CLI 的资产处理实现在 packages/cli/src/build/assets.rs,其模块头注释完整记录了设计动机:之所以把哈希计算从宏挪到构建期,是因为资源之间可能互相引用(如 CSS 引用图片),必须先解析资源内容才能为每个资源算出稳定哈希;哈希同时用于浏览器缓存失效与构建系统的优化结果缓存。该管线复用了热补丁引擎(hot-patch)的二进制解析能力,分五阶段:

Phase 1:二进制扫描

1. 读取编译产物
2. 通过 objfile 解析器查找 __ASSETS__ 符号
3. 平台特定实现:
   - Native: object crate 符号表
   - Windows PE: 解析 PDB 文件
   - WASM: 解析 walrus 的 data section
   - Android: NDK 处理

源码中 object 与 pdb 的依赖引入 印证了这套实现,且使用 rayon 做并行处理。

Phase 2:资源反序列化

对每个 __ASSETS__{hash}:读取符号指向的 4096 字节 → 先按 SymbolData 反序列化,失败则回退裸 BundledAsset(见 deserialize_manganis_payload)→ 提取 absolute_source_pathbundled_pathoptions

Phase 3:唯一资产收集

(absolute_path, options) 对去重,构建 AssetManifestBundledAssetEq/Hash 实现 正是按这三个字段定义相等与哈希,与去重策略严格对应。

Phase 4:资源优化

类型 处理
Image 缩放、格式转换、压缩优化
CSS 按 minify 选项压缩
JS 按 minify 选项压缩
Folder 递归拷贝
Unknown 直接拷贝(可选加哈希)

Phase 5:二进制回写补丁

对每个已处理资源:
1. 计算最终哈希(内容 + 选项 + 版本)
2. 构造新 BundledAsset:
   - bundled_path: "/assets/{output-filename}"
3. CBOR 序列化
4. 定位二进制中的 __ASSETS__{hash} 符号
5. 在符号偏移处覆写字节

由于每条记录定宽 4096 字节,覆写不改变符号大小,链接布局保持稳定——这是该架构能自洽的关键工程细节。

八、运行期资源解析

8.1 双路径解析

Asset::resolve() 是开发/生产双模式的实现:

pub fn resolve(&self) -> PathBuf {
    // 开发模式(!is_bundled_app()):直接返回源码绝对路径
    if !dioxus_core_types::is_bundled_app() {
        return PathBuf::from(self.bundled().absolute_source_path.as_str());
    }
    // 生产模式:base_path + /assets/ + bundled_path
    let base_path = dioxus_cli_config::base_path();
    ...
    PathBuf::from(format!("{base_path}/assets/"))
        .join(PathBuf::from(self.bundled().bundled_path.as_str().trim_start_matches('/')))
}
  • 开发模式!is_bundled_app()):返回 absolute_source_path,浏览器/原生端直接访问开发机上的原始文件;
  • 生产模式:由 base_path()(如 /app)+ /assets/ + bundled_path 拼接,得到 /app/assets/{output-filename}

同一个 Asset 常量在两种模式下都能工作(文档所称 Dual Path Resolution)。Asset 实现了 Display/DioxusFormattable,因此可直接写进 rsx 属性(img { src: ASSET }),输出即为解析后的路径字符串。

8.2 平台特定解析

  • Web (WASM)resolve_web_asset() 通过 HTTP fetch() API 拉取,支持 CORS 头,返回 Vec<u8>
  • 桌面resolve_asset_path_from_filesystem() 按各平台 bundle 结构定位——macOS 为 ../Resources/assets/,Linux 为 ../lib/{product}/assets/,Windows 为 exe 同目录 assets/(各平台的打包逻辑分别见 cli/src/bundler/macos.rslinux.rswindows.rs);
  • Androidto_java_load_asset() 走 NDK AssetManager 读取 APK 的 assets 目录,调试期回退到 /data/local/tmp/dx/(Android 侧实现见 manganis/src/android/)。

九、基于哈希的缓存失效

三层哈希各司其职:

哈希 计算时机 输入 用途
INPUT_HASH 宏展开期 span 调试串 + options 源码 + 资源路径 __ASSETS__{INPUT_HASH} 符号名
CONTENT_HASH 构建期 源文件内容 + 已应用选项 + manganis 版本 最终输出文件名
CSS_MODULE_HASH 构建期 css_module 选项 + 内容哈希 作用域化 CSS 标识符

文件名生成规则:

IMAGE:  /assets/photo.png  → /assets/photo-{hash}.webp   (可转码)
CSS/JS: /assets/style.css  → /assets/style-{hash}.css
FOLDER: /assets(目录)    → /assets/(保持不变)

由于内容哈希嵌在路径中,打包产物可以配置长过期时间的强缓存:内容不变则 URL 不变,命中缓存;内容一变哈希即变,浏览器自动拉新版本。这正是 with_hash_suffix 文档 所引用的缓存失效原理。

十、关键架构模式与扩展点

10.1 架构模式小结

结合源码可归纳出文档列出的五项模式:

  1. 常量期代码生成:宏只做 proc_macro 展开,数据生成全部落在 const fn(无分配器参与),二进制布局在编译期即确定;
  2. Volatile 读取保正确性read_volatile 防止编译期常量折叠"吃掉"构建期才会写入的 link section 数据(bundled());
  3. 布局遵从的序列化:类型须有明确内存布局(枚举 repr(C, u8)),变长字段(字符串、列表)走 CBOR;
  4. 双路径解析:开发返回源路径、生产返回打包路径,同一 Asset 实例跨配置可用;
  5. 符号即注册表:无清单文件,二进制符号表本身就是资源注册表,天然支持多 crate 场景的线性扩展。

10.2 如何新增一种资源类型

文档给出的扩展流程(以视频资源为例)与现有代码结构完全吻合,共四步:

  1. 创建选项结构体(derive 必须包含 SerializeConst 以满足常量序列化):
#[derive(SerializeConst, ...)]
pub struct VideoAssetOptions {
    format: VideoFormat,
    preload: bool,
}
  1. AssetVariant 中新增变体options.rs,注意保持 #[repr(C, u8)]):
pub enum AssetVariant {
    ...
    Video(VideoAssetOptions),
}
  1. 实现 const builder,与现有 AssetOptions::image() 等入口同构:
pub const fn video() -> AssetOptionsBuilder<VideoAssetOptions> {
    AssetOptionsBuilder::variant(VideoAssetOptions::default())
}
  1. 在 CLI 中注册处理逻辑cli/src/build/assets.rs 中的变体分派处):
AssetVariant::Video(opts) => {
    // 视频特定处理:转码、压缩等
}

由于选项随 BundledAsset 一起序列化进符号,CLI 无需其他改动即可感知新变体;extension() 等辅助方法按变体补充分支即可。

10.3 文档展望(Future Concepts)

文档还提及两个尚未实现的方向,属于构想而非现状:

  • IAAC(Infrastructure as Code):用 asset!("/config/db.yaml") 声明基础设施配置,由 CLI 提取、校验并部署;
  • secret!()const API_KEY: Secret = secret!("DIOXUS_API_KEY"),编译期校验变量存在性、运行期从安全存储注入。

十一、小结

Manganis 资源系统的技术主线可以概括为一句话:用编译期常量序列化把资源元数据变成二进制里的符号,让构建工具在链接产物上完成"读—处理—回写"闭环。关键工程决策包括:4096 字节定宽记录使回写不破坏符号布局、read_volatile + 函数指针防止编译器优化掉运行期读取、INPUT_HASHCONTENT_HASH 分层(前者标识调用点、后者标识内容)、以及符号表充当无清单注册表。对使用者而言,只需要在 crate 中声明 asset!() 常量、用 rsx 引用,即可获得跨 Web/桌面/移动端的统一资源处理与内容哈希缓存失效能力;对想扩展管线的人,则只需按"选项结构体 → AssetVariant → const builder → CLI 分派"四步接入新资源类型。相关入口文件:manganis-core/src/asset.rsmanganis-macro/src/asset.rsmanganis-macro/src/linker.rsconst-serialize/src/lib.rscli/src/build/assets.rs

登录后查看全文
热门项目推荐
相关项目推荐