Cocos Creator 运行时切换语言图集的实现方案

背景

做全球化游戏肯定要支持多语言。对于 UI 上的图片,通常用图集来管理不同语言的资源。Cocos Creator 的资源系统挺强大,但想在运行时动态切换语言图集,同时保持场景中所有引用的一致性,还是有点 tricky 的。这篇文章分享我在项目中摸索出来的方案。

多语言资源管理方案对比

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
┌─────────────────────────────────────────────────────────────────────┐
│ 多语言资源管理方案对比 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 方案一:多语言分包 │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ base.zip │ │ en.zip │ │ zh.zip │ │
│ │ 基础资源 │ │ 英文资源 │ │ 中文资源 │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
│ 优点:资源隔离清晰,按需下载 │
│ 缺点:需要重启游戏,切换体验差 │
│ │
│ ───────────────────────────────────────────────────────────────── │
│ │
│ 方案二:运行时动态替换(推荐) │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 游戏运行中 │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌─────────────────────┐ │ │
│ │ │ 切换语言按钮 │ │ │
│ │ │ (中文 → 英文) │ │ │
│ │ └──────────┬──────────┘ │ │
│ │ │ │ │
│ │ ┌──────────▼──────────┐ │ │
│ │ │ 替换图集资源 │ │ │
│ │ │ 刷新所有引用 │ │ │
│ │ └──────────┬──────────┘ │ │
│ │ │ │ │
│ │ ┌──────────▼──────────┐ │ │
│ │ │ UI 实时刷新 │ │ │
│ │ │ 无需重启 │ │ │
│ │ └─────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ 优点:无缝切换,用户体验好 │
│ 缺点:需要额外的内存管理 │
│ │
└─────────────────────────────────────────────────────────────────────┘

图集资源结构

多语言图集组织

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
assets/
├── resources/ # 动态加载资源目录
│ └── textures/
│ ├── common/ # 通用资源(无需多语言)
│ │ ├── btn_common.png
│ │ └── btn_common.plist
│ │
│ ├── i18n/ # 多语言资源
│ │ ├── CommonText_CN/ # 中文图集
│ │ │ ├── CommonText_CN.png
│ │ │ └── CommonText_CN.plist
│ │ │
│ │ ├── CommonText_EN/ # 英文图集
│ │ │ ├── CommonText_EN.png
│ │ │ └── CommonText_EN.plist
│ │ │
│ │ ├── CommonText_JP/ # 日文图集
│ │ │ ├── CommonText_JP.png
│ │ │ └── CommonText_JP.plist
│ │ │
│ │ └── CommonText_KR/ # 韩文图集
│ │ ├── CommonText_KR.png
│ │ └── CommonText_KR.plist
│ │
│ └── icons/ # 图标资源

└── scripts/
└── i18n/
└── I18nManager.ts # 多语言管理器

图集命名规范

语言 代码 图集名称示例
简体中文 zh CommonText_CN
繁体中文 zh-TW CommonText_TW
英语 en CommonText_EN
日语 ja CommonText_JP
韩语 ko CommonText_KR
法语 fr CommonText_FR
德语 de CommonText_DE
西班牙语 es CommonText_ES

核心实现原理

为什么直接替换图集不生效

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
┌─────────────────────────────────────────────────────────────────────┐
│ Cocos Creator 图集引用机制 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 场景中的 Sprite 组件: │
│ │
│ Sprite Component │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ spriteFrame: SpriteFrame │ │
│ │ ├── name: "btn_start" │ │
│ │ ├── _uuid: "f1c2..." ←── 绑定的是具体 SpriteFrame 的 UUID │ │
│ │ └── _atlas: SpriteAtlas │ │
│ │ ├── name: "CommonText_CN" │ │
│ │ └── _spriteFrames: {btn_start, btn_exit, ...} │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ 问题: │
│ 1. Sprite.spriteFrame 引用的是 SpriteFrame 实例,不是图集 │
│ 2. 每个 SpriteFrame 有唯一的 _uuid,与新图集中的 SpriteFrame 不同 │
│ 3. 直接替换图集属性不会更新已绑定的 SpriteFrame │
│ │
│ 解决方案: │
│ 替换 SpriteFrame 的显示属性,而不是替换实例本身 │
│ │
└─────────────────────────────────────────────────────────────────────┘

正确的替换方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
// I18nSpriteAtlasManager.ts

const { ccclass, property } = cc._decorator;

@ccclass
export default class I18nSpriteAtlasManager extends cc.Component {

// 单例实例
private static _instance: I18nSpriteAtlasManager = null;
public static getInstance(): I18nSpriteAtlasManager {
return I18nSpriteAtlasManager._instance;
}

// 当前语言
private _currentLanguage: string = 'CN';

// 缓存已加载的图集
private _loadedAtlases: Map<string, cc.SpriteAtlas> = new Map();

// 图集名称映射(多语言版本)
private _atlasLanguages: { [key: string]: string[] } = {
'CommonText': ['CN', 'EN', 'JP', 'KR']
};

onLoad() {
if (I18nSpriteAtlasManager._instance === null) {
I18nSpriteAtlasManager._instance = this;
cc.game.addPersistRootNode(this.node);
} else {
this.destroy();
return;
}

// 初始化加载当前语言的图集
this.loadAllAtlases(this._currentLanguage);
}

/**
* 切换语言
* @param language 目标语言代码(CN, EN, JP, KR)
*/
public async switchLanguage(language: string): Promise<void> {
if (this._currentLanguage === language) {
return;
}

cc.log(`[I18n] Switching language from ${this._currentLanguage} to ${language}`);

// 1. 预加载新语言的所有图集
await this.loadAllAtlases(language);

// 2. 执行图集替换
this.replaceAllAtlases(language);

// 3. 更新当前语言
this._currentLanguage = language;

// 4. 保存到本地存储
cc.sys.localStorage.setItem('i18n_language', language);

// 5. 触发全局语言切换事件
cc.director.emit('i18n_language_changed', language);

cc.log('[I18n] Language switch completed');
}

/**
* 加载指定语言的所有图集
*/
private loadAllAtlases(language: string): Promise<void> {
const promises: Promise<void>[] = [];

for (const baseName in this._atlasLanguages) {
const promise = this.loadAtlas(baseName, language);
promises.push(promise);
}

return Promise.all(promises).then(() => {});
}

/**
* 加载单个图集
*/
private loadAtlas(baseName: string, language: string): Promise<void> {
return new Promise((resolve, reject) => {
const atlasName = `${baseName}_${language}`;
const atlasPath = `textures/i18n/${atlasName}`;

// 检查是否已缓存
if (this._loadedAtlases.has(atlasName)) {
resolve();
return;
}

cc.loader.loadRes(atlasPath, cc.SpriteAtlas, (err, atlas) => {
if (err) {
cc.error(`[I18n] Failed to load atlas: ${atlasPath}`, err);
reject(err);
return;
}

cc.log(`[I18n] Loaded atlas: ${atlasName}`);
this._loadedAtlases.set(atlasName, atlas);
resolve();
});
});
}

/**
* 替换所有图集
* 核心逻辑:遍历已加载图集中的所有 SpriteFrame,替换其属性
*/
private replaceAllAtlases(newLanguage: string): void {
for (const baseName in this._atlasLanguages) {
const oldAtlasName = `${baseName}_${this._currentLanguage}`;
const newAtlasName = `${baseName}_${newLanguage}`;

const oldAtlas = this._loadedAtlases.get(oldAtlasName);
const newAtlas = this._loadedAtlases.get(newAtlasName);

if (oldAtlas && newAtlas) {
this.replaceAtlasSpriteFrames(oldAtlas, newAtlas);
}
}

// 刷新当前场景
this.refreshCurrentScene();
}

/**
* 替换图集中的所有 SpriteFrame 属性
* 核心实现:复制新图集的 SpriteFrame 属性到旧图集的 SpriteFrame
*/
private replaceAtlasSpriteFrames(oldAtlas: cc.SpriteAtlas, newAtlas: cc.SpriteAtlas): void {
const oldFrames = oldAtlas.getSpriteFrames();
const newFrames = newAtlas.getSpriteFrames();

// 建立新图集的 name -> SpriteFrame 映射
const newFrameMap: Map<string, cc.SpriteFrame> = new Map();
for (const frame of newFrames) {
newFrameMap.set(frame.name, frame);
}

// 遍历旧图集的所有 SpriteFrame
for (const oldFrame of oldFrames) {
const frameName = oldFrame.name;
const newFrame = newFrameMap.get(frameName);

if (newFrame) {
this.replaceSpriteFrameProperties(oldFrame, newFrame);
} else {
cc.warn(`[I18n] Frame ${frameName} not found in new atlas`);
}
}
}

/**
* 替换 SpriteFrame 的属性
* 关键:不能替换 _uuid,只能替换显示相关的属性
*/
private replaceSpriteFrameProperties(oldFrame: cc.SpriteFrame, newFrame: cc.SpriteFrame): void {
// 遍历新 SpriteFrame 的所有属性
for (const key in newFrame) {
const value = newFrame[key];

// 跳过特殊属性
if (this.shouldSkipProperty(key, value)) {
continue;
}

// 复制属性值
try {
oldFrame[key] = value;
} catch (e) {
// 某些只读属性可能无法设置,忽略错误
}
}

// 强制刷新
oldFrame['_refreshTexture'] = newFrame['_refreshTexture'];
}

/**
* 判断是否跳过属性复制
*/
private shouldSkipProperty(key: string, value: any): boolean {
// 跳过函数
if (typeof value === 'function') {
return true;
}

// 跳过特定属性
const skipKeys = [
'_uuid', // 唯一标识,不能修改
'isValid', // 内部状态
'nativeUrl', // 原生 URL
'_name', // 名称保持一致
'_atlasUuid', // 所属图集 UUID
'__instanceId', // 实例 ID
'_id' // ID
];

return skipKeys.includes(key);
}

/**
* 刷新当前场景中的所有 Sprite
*/
private refreshCurrentScene(): void {
const scene = cc.director.getScene();
if (!scene) {
return;
}

// 递归查找所有 Sprite 组件
this.refreshNodeSprites(scene);
}

/**
* 递归刷新节点的 Sprite
*/
private refreshNodeSprites(node: cc.Node): void {
// 获取当前节点的 Sprite 组件
const sprite = node.getComponent(cc.Sprite);
if (sprite && sprite.spriteFrame) {
// 标记为需要更新
sprite['_updateRenderData']();
sprite.markForUpdateRenderData();
}

// 递归处理子节点
for (const child of node.children) {
this.refreshNodeSprites(child);
}
}

/**
* 获取当前语言
*/
public getCurrentLanguage(): string {
return this._currentLanguage;
}

/**
* 从本地存储加载语言设置
*/
public loadSavedLanguage(): string {
const saved = cc.sys.localStorage.getItem('i18n_language');
if (saved) {
this._currentLanguage = saved;
}
return this._currentLanguage;
}
}

简化版实现

如果只需要基本的语言切换功能,可以使用以下简化版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
// SimpleI18nManager.ts
const { ccclass } = cc._decorator;

@ccclass
export default class SimpleI18nManager {

private static _instance: SimpleI18nManager = null;
public static getInstance(): SimpleI18nManager {
if (!this._instance) {
this._instance = new SimpleI18nManager();
}
return this._instance;
}

/**
* 切换语言图集
* @param lastAsset 当前语言的图集
* @param newAsset 目标语言的图集
* @param onComplete 切换完成回调
*/
public switchLanguageAtlas(
lastAsset: cc.SpriteAtlas,
newAsset: cc.SpriteAtlas,
onComplete?: () => void
): void {
// 获取图集中的所有 SpriteFrame
const lastFrames = lastAsset['_spriteFrames'];
const newFrames = newAsset['_spriteFrames'];

// 遍历替换属性
for (const key in lastFrames) {
const lastFrame = lastFrames[key];
const newFrame = newFrames[key];

if (!lastFrame || !newFrame) {
continue;
}

// 复制所有非函数、非特殊属性
for (const prop in newFrame) {
if (this.canCopyProperty(prop, newFrame[prop])) {
lastFrame[prop] = newFrame[prop];
}
}
}

// 刷新当前场景
this.refreshScene();

onComplete && onComplete();
}

/**
* 判断是否可以复制属性
*/
private canCopyProperty(key: string, value: any): boolean {
// 跳过函数
if (value instanceof Function) {
return false;
}

// 跳过特定属性
const skipList = ['_uuid', 'isValid', 'nativeUrl'];
return !skipList.includes(key);
}

/**
* 刷新场景显示
*/
private refreshScene(): void {
const scene = cc.director.getScene();
if (!scene) {
return;
}

// 遍历所有 Sprite 组件并刷新
const sprites = scene.getComponentsInChildren(cc.Sprite);
for (const sprite of sprites) {
if (sprite.spriteFrame) {
sprite.markForUpdateRenderData();
}
}
}

/**
* 加载并切换语言
*/
public loadAndSwitch(
baseName: string,
currentLang: string,
targetLang: string,
onComplete?: () => void
): void {
const currentPath = `textures/${baseName}_${currentLang}`;
const targetPath = `textures/${baseName}_${targetLang}`;

// 加载当前图集(获取引用)
cc.loader.loadRes(currentPath, cc.SpriteAtlas, (err, currentAtlas) => {
if (err) {
cc.error('Failed to load current atlas:', err);
return;
}

// 加载目标图集
cc.loader.loadRes(targetPath, cc.SpriteAtlas, (err, targetAtlas) => {
if (err) {
cc.error('Failed to load target atlas:', err);
return;
}

// 执行切换
this.switchLanguageAtlas(currentAtlas, targetAtlas, onComplete);
});
});
}
}

使用示例

语言切换按钮

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
// LanguageSwitchButton.ts
const { ccclass, property } = cc._decorator;

@ccclass
export default class LanguageSwitchButton extends cc.Component {

@property(cc.Label)
label: cc.Label = null;

@property([cc.String])
languageCodes: string[] = ['CN', 'EN', 'JP'];

private currentIndex: number = 0;

onLoad() {
// 加载保存的语言设置
const saved = cc.sys.localStorage.getItem('game_language');
if (saved) {
this.currentIndex = this.languageCodes.indexOf(saved);
if (this.currentIndex < 0) {
this.currentIndex = 0;
}
}

this.updateLabel();

// 绑定点击事件
this.node.on(cc.Node.EventType.TOUCH_END, this.onClick, this);
}

onClick() {
// 切换到下一个语言
this.currentIndex = (this.currentIndex + 1) % this.languageCodes.length;
const targetLang = this.languageCodes[this.currentIndex];

this.updateLabel();
this.switchLanguage(targetLang);
}

async switchLanguage(targetLang: string) {
const currentLang = this.languageCodes[this.currentIndex === 0 ?
this.languageCodes.length - 1 : this.currentIndex - 1];

// 显示加载提示
const loading = this.showLoading();

try {
// 调用管理器切换
await I18nSpriteAtlasManager.getInstance().switchLanguage(targetLang);

// 保存设置
cc.sys.localStorage.setItem('game_language', targetLang);

// 显示提示
this.showToast(`Language switched to ${targetLang}`);
} catch (err) {
cc.error('Language switch failed:', err);
this.showToast('Switch failed, please try again');
} finally {
loading.hide();
}
}

updateLabel() {
if (this.label) {
this.label.string = this.languageCodes[this.currentIndex];
}
}

showLoading() {
// 实现加载提示
return { hide: () => {} };
}

showToast(message: string) {
// 实现提示显示
cc.log('[Toast]', message);
}
}

场景初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// GameInit.ts
const { ccclass } = cc._decorator;

@ccclass
export default class GameInit extends cc.Component {

async onLoad() {
// 初始化多语言管理器
const i18nManager = I18nSpriteAtlasManager.getInstance();

// 加载保存的语言设置
const savedLang = i18nManager.loadSavedLanguage();

// 预加载语言资源
await i18nManager.switchLanguage(savedLang);

cc.log('Game initialized with language:', savedLang);
}
}

注意事项与最佳实践

1. 图集制作规范

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
┌─────────────────────────────────────────────────────────────┐
│ 多语言图集制作规范 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. 相同含义的图片使用相同的文件名 │
│ CN/btn_start.png ←→ EN/btn_start.png │
│ │
│ 2. 保持相同的尺寸和锚点 │
│ 避免因尺寸变化导致布局错乱 │
│ │
│ 3. 预留足够的安全边距 │
│ 不同语言的文本长度差异可能很大 │
│ │
│ 4. 使用统一的命名规范 │
│ 如:ui_xxx, icon_xxx, text_xxx │
│ │
└─────────────────────────────────────────────────────────────┘

2. 内存管理

1
2
3
4
5
6
7
8
9
10
// 清理不使用的图集
private unloadUnusedAtlases(keepLanguage: string): void {
for (const [name, atlas] of this._loadedAtlases) {
if (!name.endsWith(`_${keepLanguage}`)) {
// 释放资源
cc.loader.release(atlas);
this._loadedAtlases.delete(name);
}
}
}

3. 常见问题

问题 原因 解决方案
图片没有更新 属性未正确复制 检查 skipKeys 列表
布局错乱 不同语言图片尺寸不同 统一图片尺寸和锚点
内存增加 旧图集未释放 调用 cc.loader.release
切换卡顿 同步加载阻塞 使用异步加载和预加载

4. 性能优化

1
2
3
4
5
6
7
8
9
10
11
12
// 预加载下一语言资源
private preloadNextLanguage(): void {
const languages = this._atlasLanguages['CommonText'];
const currentIdx = languages.indexOf(this._currentLanguage);
const nextIdx = (currentIdx + 1) % languages.length;
const nextLang = languages[nextIdx];

// 后台静默加载
this.loadAllAtlases(nextLang).then(() => {
cc.log(`[I18n] Preloaded ${nextLang}`);
});
}

小结

Cocos Creator 多语言图集动态替换的核心要点:

  1. 理解机制:SpriteFrame 通过 UUID 引用,不能直接替换图集实例
  2. 正确方法:复制新图集 SpriteFrame 的属性到旧图集的 SpriteFrame
  3. 属性筛选:跳过 _uuidisValidnativeUrl 等特殊属性
  4. 刷新显示:切换后刷新所有 Sprite 组件的渲染数据
  5. 资源管理:及时释放不使用的图集资源,避免内存泄漏

这个方案在我参与的几个出海游戏项目里都用上了,能实现无缝的多语言切换体验。