我开发了一个星露谷风格的卡片生成器小程序,遇到最麻烦的问题就是字体的处理。微信小程序的字体处理存在诸多限制,尤其是真机环境兼容性问题,有时候在开发工具上一切都好,一上手机啥效果也没了。为了解决这些问题,主要参考了https://juejin.cn/post/7252175375105916965#heading-7这篇文章的解决办法。

微信小程序字体加载限制

问题:wx.loadFontFace没有缓存机制,用户每次打开小程序,都要重新下载字体文件。我买的阿里云oss的下行流量包,如果每次都要重新下载字体文件,有点小贵。。。

解决方案

  1. 使用wx.loadFontFace API加载字体
  2. 将字体文件转换为base64格式存储
  3. 实现字体缓存机制,避免重复下载
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 使用loadFontFace加载字体
async loadFontFaceFromBase64(fontName, base64) {
return new Promise((resolve) => {
wx.loadFontFace({
family: fontName,
global: true,
source: `data:font/ttf;base64,${base64}`,
scopes: ['webview', 'native'],
success: () => resolve(true),
fail: (err) => {
console.error(`字体加载失败: ${fontName}`, err);
resolve(false);
}
});
});
}

OpenType.js 集成

如果直接用canvas画下载好的字体,在开发工具上显示是正常的,但在真机测试的时候,渲染出的canvas的字体退回到了系统自带。微信团队好像还是没有解决这个问题。遂参考他人用OpenType.js库解决。

字体解析与缓存

OpenType.js是一个强大的字体处理库,可以将字体文件解析为可操作的JavaScript对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 字体解析核心代码
async preloadSingleFont(font) {
try {
// 从缓存读取base64数据
const base64 = await this.fetchFontBase64(font.id, font.url);

// 将base64转换为ArrayBuffer
const arrayBuffer = wx.base64ToArrayBuffer(base64);

// 使用opentype.js解析字体
const fontType = await opentype.parse(arrayBuffer);

// 缓存字体对象
this.fontCache[font.id] = {
fontType: fontType,
base64: base64
};

console.log(`字体预加载成功: ${font.name}`);
} catch (error) {
console.error(`字体预加载失败: ${font.name}`, error);
}
}

字体绘制实现

OpenType.js的核心优势在于可以将文字转换为路径对象,然后通过Canvas API进行绘制。

1. 带阴影的字体绘制

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
drawTextWithOpenType(ctx, text, x, y, fontSize, color = '#1a1a1a', fontWeight = 'normal') {
// 检查是否需要显示阴影
const shouldShowShadow = this.data.selectedFontId !== 'stardew';

if (!this.fontType) {
// 降级到默认绘制方式
this.drawDefaultText(ctx, text, x, y, fontSize, color, fontWeight);
return;
}

try {
// 字体阴影效果
if (shouldShowShadow) {
const shadowPath = this.fontType.getPath(text, x + 0, y + 2, fontSize);
const shadowColor = this.colorToRgba(color, 0.4);
shadowPath.fill = shadowColor;
shadowPath.draw(ctx);
}

// 使用opentype.js生成路径
const path = this.fontType.getPath(text, x, y, fontSize);
path.fill = color;

// 粗体效果实现
if (fontWeight === 'bold') {
this.drawBoldText(path, ctx, text, x, y, fontSize, color);
} else {
path.draw(ctx);
}
} catch (error) {
console.error('opentype.js绘制失败:', error);
// 降级处理
this.drawDefaultText(ctx, text, x, y, fontSize, color, fontWeight);
}
}

2. 粗体效果实现

由于微信小程序环境的限制,在真机测试时还是无法直接使用字体的粗体变体(也没搞懂为什么)。通过多次绘制模拟粗体效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 真机兼容的粗体实现
drawBoldText(path, ctx, text, x, y, fontSize, color) {
// 绘制原始路径
path.draw(ctx);

// 创建偏移路径,模拟粗体效果
const path1 = this.fontType.getPath(text, x + 0.5, y, fontSize);
path1.fill = color;
path1.draw(ctx);

const path2 = this.fontType.getPath(text, x, y + 0.5, fontSize);
path2.fill = color;
path2.draw(ctx);

const path3 = this.fontType.getPath(text, x + 0.5, y + 0.5, fontSize);
path3.fill = color;
path3.draw(ctx);
}

3. 文本换行处理

对于长文本,实现了自动换行功能:

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
wrapTextWithOpenType(ctx, text, x, y, maxWidth, lineHeight, color = '#2F1B14') {
if (!this.fontType) {
return this.wrapText(ctx, text, x, y, maxWidth, lineHeight, color);
}

let line = '';
let currentY = y;

for (let i = 0; i < text.length; i++) {
const testLine = line + text[i];

// 使用默认字体测量宽度(因为opentype.js测量比较复杂)
ctx.setFontSize(20);
const testWidth = ctx.measureText(testLine).width;

if (testWidth > maxWidth && i > 0) {
// 绘制当前行
this.drawTextWithOpenTypeNoShadow(ctx, line, x, currentY, 20, color);
line = text[i];
currentY += lineHeight;
} else {
line = testLine;
}
}

// 绘制最后一行
if (line) {
this.drawTextWithOpenTypeNoShadow(ctx, line, x, currentY, 20, color);
currentY += lineHeight;
}

return currentY;
}

双层缓存系统设计

文件缓存层

为了减少网络请求和提高加载速度,实现了文件缓存:

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
// 字体缓存系统
class FontCache {
// 读取字体缓存
readFontCache(fontId) {
const fs = wx.getFileSystemManager();
return new Promise((resolve) => {
fs.readFile({
filePath: `${wx.env.USER_DATA_PATH}/font-${fontId}-base64.txt`,
encoding: 'utf8',
success: (res) => {
console.log(`从缓存读取字体: ${fontId}`);
resolve(res.data);
},
fail: () => {
console.log(`缓存中未找到字体: ${fontId}`);
resolve(null);
}
});
});
}

// 写入字体缓存
writeFontCache(fontId, base64Data) {
const fs = wx.getFileSystemManager();
return new Promise((resolve) => {
fs.writeFile({
filePath: `${wx.env.USER_DATA_PATH}/font-${fontId}-base64.txt`,
data: base64Data,
encoding: 'utf8',
success: () => {
console.log(`字体缓存写入成功: ${fontId}`);
resolve(true);
},
fail: (err) => {
console.error(`体缓存写入失败: ${fontId}`, err);
resolve(false);
}
});
});
}

// 获取字体base64数据(带缓存)
async fetchFontBase64(fontId, fontUrl) {
// 先尝试从缓存读取
const cache = await this.readFontCache(fontId);
if (cache) {
return cache;
}

// 缓存中没有,从网络下载
console.log(`从网络下载字体: ${fontId}`);
return new Promise((resolve, reject) => {
wx.request({
url: fontUrl,
method: 'GET',
responseType: 'arraybuffer',
success: async (res) => {
try {
// 将arraybuffer转换为base64
const base64 = wx.arrayBufferToBase64(res.data);

// 写入缓存
await this.writeFontCache(fontId, base64);

console.log(`字体下载并缓存成功: ${fontId}, 大小: ${res.data.byteLength} bytes`);
resolve(base64);
} catch (error) {
console.error(`字体转换base64失败: ${fontId}`, error);
reject(error);
}
},
fail: (err) => {
console.error(`字体下载失败: ${fontId}`, err);
reject(err);
}
});
});
}
}

内存缓存层

最开始用上面的方法,切换还是太慢了,换一个字体要十秒钟。。。还是先加载到内存缓存快一些:

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
// 内存缓存系统
class MemoryCache {
constructor() {
this.fontCache = {};
}

// 预加载所有字体到内存缓存
async preloadAllFonts(availableFonts) {
console.log('开始预加载所有字体...');
const preloadPromises = [];

for (const font of availableFonts) {
if (font.url && !this.fontCache[font.id]) {
preloadPromises.push(this.preloadSingleFont(font));
}
}

try {
await Promise.allSettled(preloadPromises);
console.log('字体预加载完成');
} catch (error) {
console.error('字体预加载部分失败:', error);
}
}

// 快速切换字体
async fastSwitchFont(fontId, fontName) {
try {
console.log(`快速切换字体: ${fontName}...`);

// 确保字体缓存对象存在
if (!this.fontCache) {
this.fontCache = {};
}

// 如果是系统默认字体
if (fontId === 'system') {
this.fontType = null;
this.setData({ fontLoaded: true });
console.log(`快速切换到系统默认字体`);
return;
}

// 检查内存缓存
if (this.fontCache[fontId]) {
console.log(`从内存缓存快速切换字体: ${fontName}`);
this.fontType = this.fontCache[fontId].fontType;
this.setData({ fontLoaded: true });

// 异步加载到系统字体(不阻塞切换)
this.loadFontFaceFromBase64(fontName, this.fontCache[fontId].base64);
return;
}

// 如果内存缓存中没有,回退到正常加载
console.log(`内存缓存中未找到字体,回退到正常加载: ${fontName}`);
await this.loadSelectedFont();

} catch (error) {
console.error(`快速切换字体失败: ${fontName}`, error);
// 回退到正常加载
await this.loadSelectedFont();
}
}
}

九宫格边框实现

为了实现星露谷风格的边框效果,使用了九宫格技术:

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
// 九宫格边框绘制
drawBorder(ctx, width, height, borderWidth = 12) {
const slice = 75; // border-image slice
const imgPath = '/imgs/aaa.png';

wx.getImageInfo({
src: imgPath,
success: (res) => {
const imgW = res.width;
const imgH = res.height;
const path = res.path;

// 绘制九个部分
// 四个角
ctx.drawImage(path, 0, 0, slice, slice, 0, 0, borderWidth, borderWidth); // 左上
ctx.drawImage(path, imgW - slice, 0, slice, slice,
width - borderWidth, 0, borderWidth, borderWidth); // 右上
ctx.drawImage(path, 0, imgH - slice, slice, slice,
0, height - borderWidth, borderWidth, borderWidth); // 左下
ctx.drawImage(path, imgW - slice, imgH - slice, slice, slice,
width - borderWidth, height - borderWidth, borderWidth, borderWidth); // 右下

// 四个边
ctx.drawImage(path, slice, 0, imgW - 2 * slice, slice,
borderWidth, 0, width - 2 * borderWidth, borderWidth); // 上
ctx.drawImage(path, slice, imgH - slice, imgW - 2 * slice, slice,
borderWidth, height - borderWidth, width - 2 * borderWidth, borderWidth); // 下
ctx.drawImage(path, 0, slice, slice, imgH - 2 * slice,
0, borderWidth, borderWidth, height - 2 * borderWidth); // 左
ctx.drawImage(path, imgW - slice, slice, slice, imgH - 2 * slice,
width - borderWidth, borderWidth, borderWidth, height - 2 * borderWidth); // 右
}
});
}

动态属性绘制

对于卡片的属性部分,实现了动态绘制功能:

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
// 绘制动态属性
drawStats(ctx, startX, startY, width) {
const stats = this.data.itemStats.filter(stat => stat.name && stat.value);
if (stats.length === 0) return startY;

let currentY = startY;
let completedCount = 0;
let hasAsyncIcons = false;

// 设置属性文字颜色
ctx.setFillStyle('#352f36');

stats.forEach((stat, index) => {
const statY = startY + (index * 36);

if (stat.iconPath) {
hasAsyncIcons = true;
// 异步加载图标
wx.getImageInfo({
src: stat.iconPath,
success: (iconRes) => {
// 绘制图标阴影
ctx.setGlobalAlpha(0.5);
ctx.drawImage(iconRes.path, startX - 3, statY - 30 + 3, 35, 35);

// 绘制图标主体
ctx.setGlobalAlpha(1.0);
ctx.drawImage(iconRes.path, startX, statY - 30, 35, 35);

// 绘制属性文字
this.drawTextWithOpenType(ctx, `${stat.value} ${stat.name}`,
startX + 40, statY, 20, '#352f36');
completedCount++;
},
fail: () => {
// 图标加载失败,使用默认文本
this.drawTextWithOpenType(ctx, `${stat.value} ${stat.name}`,
startX, statY, 20, '#352f36');
completedCount++;
}
});
} else {
// 没有图标,直接绘制文字
this.drawTextWithOpenType(ctx, `${stat.value} ${stat.name}`,
startX, statY, 20, '#352f36');
}
});

return startY + (stats.length * 24);
}

性能优化策略

字体加载优化

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
// 预加载策略
async onLoad() {
// 初始化字体缓存对象
this.fontCache = {};
// 预加载所有字体到内存缓存
await this.preloadAllFonts();
// 加载当前选中的字体
await this.loadSelectedFont();
}

// 并行预加载
async preloadAllFonts() {
const preloadPromises = [];

for (const font of this.data.availableFonts) {
if (font.url && !this.fontCache[font.id]) {
preloadPromises.push(this.preloadSingleFont(font));
}
}

try {
await Promise.allSettled(preloadPromises);
console.log('字体预加载完成');
} catch (error) {
console.error('字体预加载部分失败:', error);
}
}

内存管理优化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 字体缓存管理
class FontCacheManager {
constructor() {
this.fontCache = {};
this.maxCacheSize = 10; // 最大缓存数量
}

// 添加字体到缓存
addFont(fontId, fontData) {
// 检查缓存大小
if (Object.keys(this.fontCache).length >= this.maxCacheSize) {
// 移除最旧的缓存
const oldestKey = Object.keys(this.fontCache)[0];
delete this.fontCache[oldestKey];
}

this.fontCache[fontId] = fontData;
}

// 清理缓存
clearCache() {
this.fontCache = {};
}
}

错误处理与降级

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
// 完善的错误处理机制
async loadSelectedFont() {
try {
// 尝试加载字体
await this.loadFontWithOpenType();
} catch (error) {
console.error('字体加载失败:', error);

// 降级处理
try {
await this.loadFontWithLoadFontFace();
} catch (fallbackError) {
console.error('降级加载也失败:', fallbackError);
// 使用系统默认字体
this.useSystemFont();
}
}
}

// 降级到系统字体
useSystemFont() {
this.fontType = null;
this.setData({ fontLoaded: true });
wx.showToast({
title: '使用系统默认字体',
icon: 'none',
duration: 2000
});
}

第一次碰前端相关,写WXML和WXSS用Cursor真的省去了很多重复劳动!我只需要聚焦于底层逻辑还有优化就可以了,把组织容器布局交给AI来做就好。