ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C++享元模式实战:从内存失控到22MB的文字渲染重构

C++享元模式实战:从内存失控到22MB的文字渲染重构

C++里的享元模式,我最早意识到它的价值是在一个渲染项目跑到一半、内存曲线失控的时候。粒子对象数量上了规模,每个对象还都背一份完整的纹理数据,一个纹理两三百KB,乘起来就是好几GB甚至更多,程序当场退出。后来重构时把共享的纹理抽出来单独做池子,粒子里只留位置、速度、生命值这些"个人数据",内存立刻回到几百MB。这件事让我彻底改变了对设计模式的看法:享元模式从来不是用来应付面试的抽象概念,它是能救项目的工具。

这篇文章我从"问题现场"讲起,拆解享元模式的核心思路,然后给一个完整的C++实战案例——文字渲染引擎的重构过程,包括接口设计、工厂实现、状态划分边界,还有我踩过的那些坑。适合正在做图形、游戏或大规模对象系统的C++开发者,也适合想弄明白设计模式到底怎么用的朋友。读完你至少能搞清楚一件事:什么时候该用享元模式,以及用的时候怎么避免写出"伪享元"。

1. 为什么要用享元模式:先看一个内存被吃光的现场

1.1 没有享元时,对象是怎么把内存吃光的

设想一个2D粒子系统,屏幕上同时存在一万个粒子。每个粒子需要保存纹理ID、颜色、位置、速度、加速度、生命值、旋转角这些实时参数,这部分其实很小,算下来一个粒子几十字节就够。真正要命的是纹理数据。一个256×256的RGBA纹理是256KB,如果每个粒子对象都直接保存一份自己的纹理数据,一万个粒子就是2.56GB。哪怕场景里实际只用了四五种纹理,也会被硬生生复制出一万份。

用代码看会更直观,问题核心就在这一行:

// 重构前:粒子对象直接持有完整纹理数据 class Particle { public: std::vector<uint8_t> texture_; // 256KB,每个粒子都拷贝一份 int x_, y_; float vx_, vy_; };

问题很明显:texture_占了大头,而百分之九十的粒子的texture_内容完全一样。真正的数据量只有四五份,却花了一万份的钱。此时第一反应可能是"每个粒子用shared_ptr指向同一份纹理",但这只是把深拷贝换成引用计数,治标不治本。每个shared_ptr都有自己的控制块,原子计数自增自减在高频粒子创建销毁下有真实开销,而且shared_ptr没法在语义上强制"这块纹理是共享的、不能改"。享元模式要解决的不只是拷贝问题,而是从设计层面把"可以共享的部分"和"必须独立的部分"明确切开。

1.2 享元模式的一拆两半:哪些该共享,哪些该独有

享元模式的核心思想只有一句话:把对象状态分成两类。内在状态是对象本身固有的、对所有使用场景都一样的数据;外在状态是随使用场景变化、必须由调用方独立保存的数据。内在状态放进共享对象,外在状态在调用时作为参数传入,不在共享对象里存下来。

用图书馆借书来类比。书架上某本C++经典只有两三本,所有借书人各自有"借书日期、还书期限、谁借走"的记录。书本身是公共资源,借书记录属于每个人自己。如果每个人都复印一整本书带回家,就是没做享元;大家到图书馆看同一本书,各自只带一张摘录卡片,就是享元模式。内在状态是"书的内容",外在状态是"这张借书卡"。

一个容易混淆的点是:很多人以为享元模式只是"多个对象共享同一个数据副本",于是直接用静态成员或者全局缓存来替代。这其实偏离了方向。享元的重点在于通过统一的工厂管理共享对象的创建和获取,并且明确划分哪些状态是共享体自带的,哪些状态是调用时传进来的。只有两者都做到,代码才不会在规模扩大后重新回到"到处拷贝、到处持有重复数据"的老路上。

2. 接口与工厂:C++里实现享元的关键设计

2.1 共享体怎么设计:抽象接口与具体享元

以文字渲染为例。"文字"这个对象天然有两类状态。内在状态是字符编码、字体名称、字形轮廓或栅格化位图,无论这个'a'画在屏幕左上角还是右下角,它的字形长相都相同。外在状态是绘制坐标、字号、颜色、旋转角度,每画一次都可能不同。

// 抽象享元:定义对外接口 class Glyph { public: virtual ~Glyph() = default; // 外在状态全部通过参数传入 virtual void draw(float x, float y, float scale, uint32_t color) const = 0; int width() const { return width_; } int height() const { return height_; } protected: int width_ = 0; int height_ = 0; }; // 具体享元:保存内在状态 class BitmapGlyph final : public Glyph { public: BitmapGlyph(uint32_t code, std::vector<uint8_t> bitmap, int w, int h) : code_(code), bitmap_(std::move(bitmap)) { width_ = w; height_ = h; } void draw(float x, float y, float scale, uint32_t color) const override { // 用 code_ / bitmap_ 结合 x/y/scale/color 生成绘制指令 // 实际引擎里这里可能往渲染命令队列塞一条记录 } private: uint32_t code_; // 内在状态:字符编码 std::vector<uint8_t> bitmap_; // 内在状态:栅格化位图,只读共享 };

这段代码藏着三个关键决策,我一个个说。

第一,内在状态必须是只读的。bitmap_在构造函数里填好,之后谁都不能改。如果想换字体、换字号对应的位图版本,那就创建一个新的BitmapGlyph对象,而不是修改已有的。很多享元实现写到后面漏气,就是因为在某个函数里出现了"既然拿到这个字形了,顺便改一下"的念头。哪怕只是缓存一个派生值、加一个调试标记,严格来讲都算破坏只读约束。如果你想给字形附加额外信息,正确做法是装饰器或者另建映射表,而不是往字形对象上写。

第二,draw必须是const。这不仅是为了语法正确,更是在语义上向所有调用方承诺:绘制过程不会改变共享对象。有了这个承诺,同一个Glyph才能放心地同时传给界面不同位置的文字渲染请求,也可以跨线程读取。编译器也会帮你拦住那些试图修改字形的代码——如果有人在你后面维护代码时不小心往draw里写了个成员变量赋值,编译期就会直接报错,这比任何评审意见都硬核。

第三,外在状态作为参数传入。如果你发现某个值在每次调用时都在变化、而你又想把它存进享元对象的成员变量里,那就说明这个值应该是外在状态。这是整篇文章里我个人觉得最实用的一条判断标准。

2.2 工厂机制:用查表代替重复构造

有了共享对象,还需要统一入口。没有工厂的话,调用方各写各的缓存,很容易出现"同一个字符画出了两份字形对象"的场面,享元就名存实亡。工厂的职责是:请求字形时先去池子查,存在就直接返回,不存在就加载一个再放进池子。

class GlyphFactory { public: const Glyph* getGlyph(uint32_t code, const std::string& fontName) { auto key = fontName + "#" + std::to_string(code); auto it = cache_.find(key); if (it != cache_.end()) { return it->second.get(); } // 真正是字形加载:打开字体文件、解析轮廓、栅格化位图 // 这里用模拟数据代替 auto bitmap = std::vector<uint8_t>(64 * 64 * 4, 255); auto glyph = std::make_unique<BitmapGlyph>( code, std::move(bitmap), 64, 64); auto* raw = glyph.get(); cache_.emplace(std::move(key), std::move(glyph)); return raw; } private: std::unordered_map<std::string, std::unique_ptr<Glyph>> cache_; };

这里为什么用std::unordered_map<std::string, std::unique_ptr<Glyph>>而不是shared_ptr?首先是所有权清晰:字形对象的生命周期完全由工厂管理,外部拿到的只是观察指针,不需要也不应该负责释放。其次是性能:shared_ptr每次拷贝都要动原子计数,高频请求下是不必要的热点。unique_ptr加裸指针的组合是最贴近享元本意的C++写法——共享体只有一个所有者,其他使用者只读不持有。

键的设计值得单独强调。键是fontName + "#" + code,而不是只放code。同样一个字符'a',在不同字体下栅格化出来的轮廓和位图完全不同,如果漏掉字体名,就会出现"明明是另一套字体,却复用了上一套字体的字形"这种很难查的渲染错乱。等字号也参与位图生成时,键还得继续加维度,比如字号、是否加粗、是否斜体。

如果字符集是固定且有限的,还有一种更快的做法:用std::vector<std::unique_ptr<Glyph>>按码位直接索引,替代哈希表。哈希查找虽然不慢,但和数组索引比起来还是有差距,在每帧查几千次的场景里,这点差距会累积成肉眼可见的耗时。我建议先按通用哈希表实现,跑起来后用profiler决定要不要优化成数组。

注意:GlyphFactory返回的指针只在工厂生命周期内有效。使用方不要尝试delete,也不要把它长期保存后跨异步任务使用。这一点后面踩坑部分还会展开。

2.3 内在状态和外在状态的边界:一张判断表

划分边界是享元模式里最容易被搞砸的地方。我整理了一张表,实战里遇到拿不准的情况就对着问一遍:

判断问题答案是"是"答案是否
这个数据在N个使用场景里都一样吗?内在状态外在状态
这个数据会随调用参数变化吗?外在状态内在状态
这个数据是对象身份的核心吗?内在状态外在状态
多个线程同时读取会冲突吗?可以放入内在状态严禁放入内在状态
能否在构造时一次性确定?内在状态外在状态

举一个经典例子:棋盘游戏。车、马、炮的精灵图是内在状态,棋盘上的格子位置是外在状态。如果把棋子的坐标x、y写进共享精灵图对象里,那棋盘上有十个红车就会互相覆盖位置,最后画出来的场景全串位。这种错误通常在逻辑上很难发现,因为单个对象看起来是对的,只有在多个实例同时用同一个共享对象时才会暴露——越是这种"并发才会显现"的问题,越要在设计阶段拦住。

3. 实战:重构一个文字渲染引擎

3.1 场景设定:20万次绘制的文字系统

假设我们做一个跨平台的UI文字渲染模块。界面层有按钮、列表、聊天框、日志面板,一次场景里同时存在的文字实例可能到20万个。聊天滚动区和日志面板叠加的时候,这个数字一点都不夸张。如果每个文字实例都保存一份自己的字形位图,按64×64的RGBA位图算,一份16KB,20万份就是3.2GB,在桌面端可能扛得住,在移动端或内存受限环境基本必挂。但注意,这些文字实例翻来覆去只用了字体文件里几百个不同字符,字形数据完全可以共享。

这就是享元模式在文字渲染里如此自然的原因:重复率极高、内在状态占用大、外在状态很小。它几乎是教科书级的应用场景。更重要的是,字形加载本身是重操作,涉及文件I/O、字体解析和位图栅格化,如果不做共享,每个文字实例都重新加载一次字形,CPU会直接烧在重复加载上——这比内存问题更隐蔽,也更难排查。

3.2 完整代码骨架:从字形类到工厂再到使用方

下面这套代码是一个可运行的骨架,我按"共享体 → 工厂 → 使用方"的顺序写完整。

#include <cstdint> #include <memory> #include <string> #include <unordered_map> #include <vector> // 1. 内在状态:字形数据(共享、只读) class Glyph { public: virtual ~Glyph() = default; virtual void draw(float x, float y, float scale, uint32_t color) const = 0; }; class BitmapGlyph final : public Glyph { public: BitmapGlyph(uint32_t code, std::vector<uint8_t> bitmap, int w, int h) : code_(code), bitmap_(std::move(bitmap)) { width_ = w; height_ = h; } void draw(float x, float y, float scale, uint32_t color) const override { // 这里生成带坐标缩放的绘制指令 } private: uint32_t code_; std::vector<uint8_t> bitmap_; int width_ = 0; int height_ = 0; }; // 2. 工厂:统一管理字形生命周期 class GlyphFactory { public: const Glyph* getGlyph(uint32_t code, const std::string& fontName) { auto key = fontName + "#" + std::to_string(code); auto it = cache_.find(key); if (it != cache_.end()) { return it->second.get(); } // 模拟一次代价较高的字形加载 auto bitmap = std::vector<uint8_t>(64 * 64 * 4, 255); auto glyph = std::make_unique<BitmapGlyph>( code, std::move(bitmap), 64, 64); auto* raw = glyph.get(); cache_.emplace(std::move(key), std::move(glyph)); return raw; } private: std::unordered_map<std::string, std::unique_ptr<Glyph>> cache_; }; // 3. 使用方:保存外在状态 + 指向共享字形的指针 class TextInstance { public: TextInstance(const Glyph* glyph, float x, float y, float scale, uint32_t color) : glyph_(glyph), x_(x), y_(y), scale_(scale), color_(color) {} void draw() const { glyph_->draw(x_, y_, scale_, color_); } private: const Glyph* glyph_; // 共享,来自工厂 float x_, y_, scale_; // 外在状态,每个实例独立 uint32_t color_; };

调用方流程也很简单:

GlyphFactory factory; std::vector<TextInstance> texts; texts.reserve(5000); for (int i = 0; i < 5000; ++i) { uint32_t code = static_cast<uint32_t>('A' + (i % 26)); const Glyph* glyph = factory.getGlyph(code, "font.ttf"); texts.emplace_back(glyph, i * 1.0f, 0.0f, 1.0f, 0xFFFFFFFF); } for (const auto& t : texts) { t.draw(); }

这段代码的精髓在于:5000个实例,但底层最多只有26个字形对象(取决于循环里生成的字符范围)。每个TextInstance只保存一个常量指针加几个float,大约二三十字节,5000个实例总内存约100KB到150KB。如果不用享元,光位图就接近80MB。省下来的不仅是内存,还有字形加载的耗时——同一个字符的字形只需要栅格化一次。而且注意TextInstance不再持有字形位图的所有权,绘制操作完全委托给共享的Glyph对象,后续哪怕要换一套字体系统,只需要改工厂内部的加载策略,调用方代码一行都不用动。

3.3 三个容易被忽略的设计细节

第一个细节:返回const Glyph*而不是shared_ptr<Glyph>,这是有意为之。工厂持有unique_ptr,外界拿裸指针,语义是"借你一个只读视图,所有权你别碰"。代价是使用方必须保证指针生命周期不越过工厂生命周期。实践上我会把GlyphFactory放进渲染上下文对象里,和上下文同生共死,这样生命周期约定就很自然。如果外部确实有异步任务需要长期持有字形指针,那再用shared_ptr,但此时要仔细权衡引用计数的开销是否可接受。

第二个细节:TextInstance里的glyph_必须是指针或引用,绝对不能是Glyph对象。如果写成Glyph glyph_;,编译器会在构造时进行一次拷贝,每个TextInstance都会复制一份完整的字形数据,享元模式的意义就完全丧失了。这一点在代码评审里我见过不止一次,值得警惕。

第三个细节:靠参数传入外在状态时,要分清哪些是"调用时刻的状态",哪些其实应该参与"对象身份"。比如字号:如果字形是位图,每种字号都要单独的栅格化结果,那么字号就应该进入工厂键,而不是在draw时缩放。如果字形存的是矢量轮廓,那字号可以放到draw参数里随意缩放。这个区别直接影响享元对象的粒度,定错方向会导致缓存膨胀或渲染质量下降。

4. 踩坑记录与实测数据复盘

4.1 一组实测数据:共享前后差了多少

按前面的场景估算一下,结果会非常直观:

方案内存估算说明
不共享,每个实例保存位图200000 × 16KB = 3.2GB几乎必然OOM或被杀
只共享字形位图(享元)1000 × 16KB = 16MB内存下降两个数量级
字形位图 + 实例外在状态16MB + 200000 × 32B ≈ 22MB非常可控

表格里的数字用64×64 RGBA位图和32字节外在状态估算。不共享的方案3.2GB,大概率直接触发内存问题;享元方案22MB左右,下降了将近两个数量级。这还只是静态内存,没算上哈希表开销和分配器碎片,但即便都算上,量级也不变。实际项目里还可以做多级缓存——高频字符常驻,不常用字符LRU淘汰。因为字形工厂对外只暴露getGlyph接口,内部缓存策略如何演化,外部使用方完全无感,这又是工厂这一层带来的附加红利。

4.2 最常见的三个坑

坑一:往共享对象里塞调用状态。表现是给BitmapGlyph加一个setPosition(x, y),每次draw之前调用set。单线程单实例跑起来没问题,多实例或多线程一跑,位置就互相覆盖。解决方式我刚才反复强调过:draw必须是const,调用相关的字段一律不放成员变量。这是享元设计的红线,没有例外。

注意:享元对象的成员变量必须是构造后不再变化的内在状态,任何与具体调用相关的数据都要走参数传递。

坑二:裸指针悬垂。唯一所有者在工厂,使用方拿裸指针,如果工厂先析构、使用方后使用,就是空悬指针。务实的处理方法是:把工厂做成全局单例或渲染上下文的子对象,并在团队文档里明确"字形指针只在上下文周期内有效"。如果代码里出现了跨异步任务持指针的场景,必须改成shared_ptr再谈别的。这里还有一个容易犯的次生错误:想着用shared_ptr解决一切,于是把工厂返回值改成shared_ptr,然后发现每个调用方都开始存一份shared_ptr,引用计数开销上去了,所有权也开始模糊,工厂想统一回收缓存对象时还要担心外部引用是不是还没释放。总之从unique_ptr加观察指针起步,只有出现明确的失控场景再考虑shared_ptr。

坑三:并发访问。渲染线程和资源加载线程同时进入getGlyph,unordered_map的读写并发就会触发未定义行为。最简单的方案是给getGlyph加锁,锁只覆盖查询和插入那一小段,返回裸指针后使用字形时不需要锁,因为字形对象由工厂持有、生命期有保证。更进一步可以用双重检查锁,先无锁查一次,miss了再加锁二次查。要注意编译器内存序问题,用std::mutex是最稳的,不要自己手写原子标志。

还有一个容易忽略的点:字形加载本身是重操作。文件I/O、字体解析、位图栅格化,一个字符经常要几毫秒到几十毫秒。享元模式把这部分成本平摊成"每个字符最多加载一次",这比单纯的内存收益更值钱。尤其做启动预加载时,先预热最常用的几十个字符的字形,运行期getGlyph就退化成了哈希查找,不会出现第一帧突然卡一下的体验问题。

4.3 什么时候别用享元模式

享元不是万能药。如果对象的每个实例状态都不同,共享率很低,强行抽内在状态只会让代码绕一大圈,收益却微乎其微。比如迭代器、临时计算上下文这类小对象,创建开销小、状态独立,直接用普通对象就好。

判断收益有一个粗略公式:收益 = (N - M) × 每份内在状态字节数。N是实例数,M是不同的内在状态种数。粒子系统那个例子,N=10万,M≈5,单份纹理256KB,收益约2.5GB,非常值得。如果M接近N,即每个对象的内在状态都不同,收益趋近于零,那就别折腾了。

另一个更实用的判据:如果创建对象伴随高成本资源加载——文件I/O、数据库查询、复杂解析——哪怕内存没爆,也值得用享元缓存结果。我见过不少案例,内存没有问题,但每次创建对象都重新解析一份大JSON、重新打开一次文件,CPU就莫名其妙烧完了。把享元当作"结果缓存"来用,同样是它的正确打开方式。

我个人做下来,最值钱的不是记住了享元模式的类图,而是形成了"先分内在/外在状态,再动手写类"的习惯。每次要写一种会被大量实例化的数据类,我都会先问自己三个问题:哪些字段在所有实例间完全相同?哪些字段每个实例不同?是否值得把相同的部分抽出来共享?这三个问题想清楚,很多内存膨胀问题会在设计阶段被直接消灭,而不是等线上OOM了再加班重构。

最后再分享一个小技巧:写享元工厂的时候,先把池子预分配好,并主动预热那些高频使用的对象。这样运行期获取操作基本就是一次哈希查找,不会出现"第一帧突然卡一下"的情况。缓存的粒度、键的维度、线程策略都可以后续逐步演进,但"共享与独有分离"这个核心骨架,一定要在第一次设计时就立住。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进