4. 资源压缩基础

字符串资源在嵌入式GUI里,看着不起眼,其实是个大坑。我见过不少项目,前期图省事把字符串直接硬编码在代码里,后期加多语言时整个推倒重来。说白了,字符串管理的好坏,直接决定了你的GUI能不能走向全球市场。

这一章我们重点聊聊:字符串表怎么设计、多语言怎么支持、Unicode编码怎么选、以及怎么把字符串压扁存好。

4.1 字符串表的核心设计思路

字符串表,就是把所有界面文字集中管理。每个字符串分配一个唯一ID,代码里只引用ID,不直接写文字。

核心原则:代码与文字彻底分离。文字是数据,不是代码。

我习惯用枚举或宏定义来管理字符串ID:

// string_ids.h
typedef enum {
    STR_ID_OK = 0,
    STR_ID_CANCEL,
    STR_ID_CONFIRM,
    STR_ID_WARNING,
    STR_ID_ERROR,
    STR_ID_LOADING,
    // ... 后续新增直接追加
    STR_ID_COUNT   // 用于数组大小
} StringID;

对应的字符串表结构:

// string_table.h
typedef struct {
    uint16_t id;
    const char* text;
} StringEntry;

// 英文版
const StringEntry g_string_table_en[] = {
    {STR_ID_OK, "OK"},
    {STR_ID_CANCEL, "Cancel"},
    {STR_ID_CONFIRM, "Confirm"},
    // ...
};

这样做的好处很明显:

  • 新增语言只需加一张表,不改代码逻辑
  • 字符串可以放在外部存储,运行时按需加载
  • 方便做压缩和校验

我的经验:ID分配时留一些间隔(比如每10个一组),方便后期插入新字符串而不打乱已有ID顺序。我曾经吃过这个亏,后来就学乖了。

4.2 多语言支持(i18n/l10n)的落地实践

国际化(i18n)和本地化(l10n)听起来高大上,其实在嵌入式里就是三件事:语言切换、文本方向、区域格式。

4.2.1 语言切换机制

我推荐用查表法,运行时根据当前语言ID切换字符串表指针:

typedef enum {
    LANG_EN = 0,
    LANG_ZH_CN,
    LANG_JA,
    LANG_KO,
    LANG_COUNT
} LanguageID;

const StringEntry* g_current_table = NULL;

void gui_set_language(LanguageID lang) {
    switch(lang) {
        case LANG_EN:    g_current_table = g_string_table_en; break;
        case LANG_ZH_CN: g_current_table = g_string_table_zh; break;
        case LANG_JA:    g_current_table = g_string_table_ja; break;
        case LANG_KO:    g_current_table = g_string_table_ko; break;
        default:         g_current_table = g_string_table_en; break;
    }
}

const char* gui_get_string(StringID id) {
    // 二分查找或直接索引(如果ID连续)
    return g_current_table[id].text;
}

注意:不同语言的字符串长度差异很大。比如德语比英语长30%~50%,中文反而短。UI布局必须考虑最大字符串长度,否则会出现文字截断或重叠。我曾经在俄语版本上翻过车,按钮文字直接跑出边界了。

4.2.2 文本方向与区域格式

阿拉伯语和希伯来语是从右往左书写的。如果你的GUI要支持这些语言,字符串表里还得存一个方向标志:

typedef struct {
    uint16_t id;
    const char* text;
    uint8_t direction;  // 0=LTR, 1=RTL
} StringEntryEx;

日期、时间、数字格式也因地区而异。我建议把这些格式信息也放在语言配置结构体里:

typedef struct {
    LanguageID lang_id;
    const char* date_format;   // "YYYY-MM-DD" 或 "DD/MM/YYYY"
    const char* time_format;   // "HH:MM:SS" 或 "HH:MM:SS AM"
    const char* decimal_point; // "." 或 ","
    uint8_t text_direction;
} LocaleConfig;

4.3 Unicode编码:UTF-8 vs UTF-16

嵌入式系统里,Unicode编码的选择直接影响存储和性能。我主要用两种:UTF-8和UTF-16。

特性 UTF-8 UTF-16
存储效率(英文) 1字节/字符 2字节/字符
存储效率(中文) 3字节/字符 2字节/字符
兼容ASCII 完全兼容 不兼容
处理复杂度 变长编码,需解析 基本定长,处理简单
内存对齐 无需对齐 需2字节对齐

我个人建议:

  • 存储用UTF-8:节省空间,兼容性好,尤其适合英文为主的界面
  • 运行时用UTF-16:处理速度快,适合需要频繁索引字符的场景

实际项目中,我经常在编译时把UTF-8字符串转成UTF-16存到外部Flash,运行时直接读取。这样兼顾了存储效率和运行速度。

避坑指南:UTF-8的BOM(Byte Order Mark)在嵌入式里尽量别用。很多编译器会把它当成普通字符处理,导致字符串开头多出3个字节。我曾经排查了一整天,才发现是BOM搞的鬼。

4.4 字符串压缩:把空间榨干

嵌入式Flash寸土寸金,字符串压缩是必修课。我常用的方法有几种:

4.4.1 字典压缩

把重复出现的子串(比如"Error"、"Warning")提取成字典项,字符串表里只存索引:

// 字典
const char* dict[] = {
    "Error",
    "Warning",
    "Information",
    "Please wait",
    // ...
};

// 压缩后的字符串表
const uint16_t compressed_strings[] = {
    0,          // "Error"
    1,          // "Warning"
    2,          // "Information"
    0, 3, 4,    // "Error: Please wait"
};

4.4.2 LZSS/LZ4压缩

对于整个字符串表,可以用LZSS或LZ4算法整体压缩。运行时解压到RAM缓存。我测试过,LZ4对中文文本的压缩率能达到40%~50%。

// 伪代码:解压字符串表
uint8_t compressed_data[COMPRESSED_SIZE];
uint8_t decompress_buffer[DECOMPRESSED_SIZE];

void load_string_table(void) {
    // 从外部Flash读取压缩数据
    flash_read(STRING_TABLE_ADDR, compressed_data, COMPRESSED_SIZE);
    // LZ4解压
    LZ4_decompress_safe(compressed_data, decompress_buffer, 
                        COMPRESSED_SIZE, DECOMPRESSED_SIZE);
}

注意:解压需要额外的RAM。如果你的MCU只有几十KB RAM,要算好缓冲区大小。我建议只解压当前语言需要的部分,不要一次性全解。

4.4.3 外部存储方案

当字符串表超过几十KB时,放内部Flash就不划算了。我通常把字符串表放在外部SPI Flash或SD卡里:

// 外部存储布局
// 地址 0x0000: 字符串表索引(每个字符串的偏移量和长度)
// 地址 0x0100: 压缩后的字符串数据

typedef struct {
    uint32_t offset;  // 在外部Flash中的偏移
    uint16_t length;  // 压缩后的长度
} StringIndex;

StringIndex g_string_index[STR_ID_COUNT];

void gui_load_string(StringID id, char* buffer, uint16_t max_len) {
    StringIndex* idx = &g_string_index[id];
    uint8_t compressed[256];
    
    // 读取压缩数据
    spi_flash_read(STRING_DATA_BASE + idx->offset, compressed, idx->length);
    // 解压到buffer
    lz4_decompress(compressed, idx->length, buffer, &max_len);
}

4.5 本章知识体系总览

下面这张图把字符串与国际化资源管理的核心流程串起来了:

字符串与国际化资源管理架构图 字符串与国际化资源管理架构 字符串表设计(ID + 文本) 多语言支持(i18n/l10n) 语言切换机制 Unicode编码选择 文本方向/区域格式 字符串压缩与外部存储 字典压缩 LZSS/LZ4压缩 外部Flash/SD卡 目标:存储最小化 + 运行时快速访问 + 易于扩展多语言

4.6 实战建议与避坑总结

做了这么多年嵌入式GUI,关于字符串这块我总结了几条铁律:

  • 永远不要硬编码字符串——哪怕你觉得"这个界面永远不会改"
  • 字符串ID用枚举管理——比宏定义更安全,编译器能帮你检查
  • UTF-8是存储首选——除非你的系统对中文处理性能要求极高
  • 压缩要权衡——字典压缩适合重复多的场景,LZ4适合整体压缩
  • 外部存储要加缓存——频繁读Flash会拖慢UI响应

我曾经踩过的坑:有一次做多语言支持,中文和日文混在一起,结果UTF-8编码解析出错,界面显示全是乱码。后来排查发现是字符串表里混入了GB2312编码的字符。所以,整个项目必须统一编码,最好在构建脚本里加个编码检查步骤。

嗯,字符串管理这块内容不少,但只要你把表设计好、编码选对、压缩策略定下来,后面维护起来会非常省心。记住:好的字符串架构,能让你的GUI轻松支持十几种语言,而不用改一行C代码。