恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
C++享元模式实战:从内存失控到22MB的文字渲染重构
首页
资讯中心
/
C++享元模式实战:从内存失控到22MB的文字渲染重构
C++享元模式实战:从内存失控到22MB的文字渲染重构
发布时间:2026/10/15 12:01:04
C里的享元模式我最早意识到它的价值是在一个渲染项目跑到一半、内存曲线失控的时候。粒子对象数量上了规模每个对象还都背一份完整的纹理数据一个纹理两三百KB乘起来就是好几GB甚至更多程序当场退出。后来重构时把共享的纹理抽出来单独做池子粒子里只留位置、速度、生命值这些个人数据内存立刻回到几百MB。这件事让我彻底改变了对设计模式的看法享元模式从来不是用来应付面试的抽象概念它是能救项目的工具。这篇文章我从问题现场讲起拆解享元模式的核心思路然后给一个完整的C实战案例——文字渲染引擎的重构过程包括接口设计、工厂实现、状态划分边界还有我踩过的那些坑。适合正在做图形、游戏或大规模对象系统的C开发者也适合想弄明白设计模式到底怎么用的朋友。读完你至少能搞清楚一件事什么时候该用享元模式以及用的时候怎么避免写出伪享元。1. 为什么要用享元模式先看一个内存被吃光的现场1.1 没有享元时对象是怎么把内存吃光的设想一个2D粒子系统屏幕上同时存在一万个粒子。每个粒子需要保存纹理ID、颜色、位置、速度、加速度、生命值、旋转角这些实时参数这部分其实很小算下来一个粒子几十字节就够。真正要命的是纹理数据。一个256×256的RGBA纹理是256KB如果每个粒子对象都直接保存一份自己的纹理数据一万个粒子就是2.56GB。哪怕场景里实际只用了四五种纹理也会被硬生生复制出一万份。用代码看会更直观问题核心就在这一行// 重构前粒子对象直接持有完整纹理数据 class Particle { public: std::vectoruint8_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::vectoruint8_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::vectoruint8_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::vectoruint8_t(64 * 64 * 4, 255); auto glyph std::make_uniqueBitmapGlyph( code, std::move(bitmap), 64, 64); auto* raw glyph.get(); cache_.emplace(std::move(key), std::move(glyph)); return raw; } private: std::unordered_mapstd::string, std::unique_ptrGlyph cache_; };这里为什么用std::unordered_mapstd::string, std::unique_ptrGlyph而不是shared_ptr首先是所有权清晰字形对象的生命周期完全由工厂管理外部拿到的只是观察指针不需要也不应该负责释放。其次是性能shared_ptr每次拷贝都要动原子计数高频请求下是不必要的热点。unique_ptr加裸指针的组合是最贴近享元本意的C写法——共享体只有一个所有者其他使用者只读不持有。键的设计值得单独强调。键是fontName # code而不是只放code。同样一个字符a在不同字体下栅格化出来的轮廓和位图完全不同如果漏掉字体名就会出现明明是另一套字体却复用了上一套字体的字形这种很难查的渲染错乱。等字号也参与位图生成时键还得继续加维度比如字号、是否加粗、是否斜体。如果字符集是固定且有限的还有一种更快的做法用std::vectorstd::unique_ptrGlyph按码位直接索引替代哈希表。哈希查找虽然不慢但和数组索引比起来还是有差距在每帧查几千次的场景里这点差距会累积成肉眼可见的耗时。我建议先按通用哈希表实现跑起来后用profiler决定要不要优化成数组。注意GlyphFactory返回的指针只在工厂生命周期内有效。使用方不要尝试delete也不要把它长期保存后跨异步任务使用。这一点后面踩坑部分还会展开。2.3 内在状态和外在状态的边界一张判断表划分边界是享元模式里最容易被搞砸的地方。我整理了一张表实战里遇到拿不准的情况就对着问一遍判断问题答案是是答案是否这个数据在N个使用场景里都一样吗内在状态外在状态这个数据会随调用参数变化吗外在状态内在状态这个数据是对象身份的核心吗内在状态外在状态多个线程同时读取会冲突吗可以放入内在状态严禁放入内在状态能否在构造时一次性确定内在状态外在状态举一个经典例子棋盘游戏。车、马、炮的精灵图是内在状态棋盘上的格子位置是外在状态。如果把棋子的坐标x、y写进共享精灵图对象里那棋盘上有十个红车就会互相覆盖位置最后画出来的场景全串位。这种错误通常在逻辑上很难发现因为单个对象看起来是对的只有在多个实例同时用同一个共享对象时才会暴露——越是这种并发才会显现的问题越要在设计阶段拦住。3. 实战重构一个文字渲染引擎3.1 场景设定20万次绘制的文字系统假设我们做一个跨平台的UI文字渲染模块。界面层有按钮、列表、聊天框、日志面板一次场景里同时存在的文字实例可能到20万个。聊天滚动区和日志面板叠加的时候这个数字一点都不夸张。如果每个文字实例都保存一份自己的字形位图按64×64的RGBA位图算一份16KB20万份就是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::vectoruint8_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::vectoruint8_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::vectoruint8_t(64 * 64 * 4, 255); auto glyph std::make_uniqueBitmapGlyph( code, std::move(bitmap), 64, 64); auto* raw glyph.get(); cache_.emplace(std::move(key), std::move(glyph)); return raw; } private: std::unordered_mapstd::string, std::unique_ptrGlyph 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::vectorTextInstance texts; texts.reserve(5000); for (int i 0; i 5000; i) { uint32_t code static_castuint32_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_ptrGlyph这是有意为之。工厂持有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。坑三并发访问。渲染线程和资源加载线程同时进入getGlyphunordered_map的读写并发就会触发未定义行为。最简单的方案是给getGlyph加锁锁只覆盖查询和插入那一小段返回裸指针后使用字形时不需要锁因为字形对象由工厂持有、生命期有保证。更进一步可以用双重检查锁先无锁查一次miss了再加锁二次查。要注意编译器内存序问题用std::mutex是最稳的不要自己手写原子标志。还有一个容易忽略的点字形加载本身是重操作。文件I/O、字体解析、位图栅格化一个字符经常要几毫秒到几十毫秒。享元模式把这部分成本平摊成每个字符最多加载一次这比单纯的内存收益更值钱。尤其做启动预加载时先预热最常用的几十个字符的字形运行期getGlyph就退化成了哈希查找不会出现第一帧突然卡一下的体验问题。4.3 什么时候别用享元模式享元不是万能药。如果对象的每个实例状态都不同共享率很低强行抽内在状态只会让代码绕一大圈收益却微乎其微。比如迭代器、临时计算上下文这类小对象创建开销小、状态独立直接用普通对象就好。判断收益有一个粗略公式收益 (N - M) × 每份内在状态字节数。N是实例数M是不同的内在状态种数。粒子系统那个例子N10万M≈5单份纹理256KB收益约2.5GB非常值得。如果M接近N即每个对象的内在状态都不同收益趋近于零那就别折腾了。另一个更实用的判据如果创建对象伴随高成本资源加载——文件I/O、数据库查询、复杂解析——哪怕内存没爆也值得用享元缓存结果。我见过不少案例内存没有问题但每次创建对象都重新解析一份大JSON、重新打开一次文件CPU就莫名其妙烧完了。把享元当作结果缓存来用同样是它的正确打开方式。我个人做下来最值钱的不是记住了享元模式的类图而是形成了先分内在/外在状态再动手写类的习惯。每次要写一种会被大量实例化的数据类我都会先问自己三个问题哪些字段在所有实例间完全相同哪些字段每个实例不同是否值得把相同的部分抽出来共享这三个问题想清楚很多内存膨胀问题会在设计阶段被直接消灭而不是等线上OOM了再加班重构。最后再分享一个小技巧写享元工厂的时候先把池子预分配好并主动预热那些高频使用的对象。这样运行期获取操作基本就是一次哈希查找不会出现第一帧突然卡一下的情况。缓存的粒度、键的维度、线程策略都可以后续逐步演进但共享与独有分离这个核心骨架一定要在第一次设计时就立住。