前言
上一篇我研究了 BootAnimation.cpp 里默认 Android logo 的 shine 动画. 那段逻辑的核心是两张纹理: 一张很长的高光图, 一张带 alpha 的 mask.
继续往下看源码时, 会发现开机动画还可以在画面上叠加时间. 它并不是调用系统字体渲染, 也不是把时间预先做成图片帧, 而是使用一张 clock_font.png 字体图集, 从里面裁剪出每个字符, 然后逐字绘制到屏幕上.
这篇文章依旧只关注一个问题: BootAnimation 是如何把当前时间绘制到开机动画上的.
资源获取
时间绘制用到的资源是 clock_font.png, 我把它和上一篇的资源放在同一个目录:
public/images/archives/shine-logo/clock_font.png
这张图片尺寸是 640 x 576. 表面上看它只是黑底白字, 但源码会把它当成一个固定规格的字符表:
- 一共有
16列 - 可打印 ASCII 字符范围是空格
' '到'~' - 每个字符格子的宽度是
640 / 16 = 40 - 字符表一共有
6行 - 每一行又分成上下两半: 上半是 regular, 下半是 bold
- 所以实际字符高度是
576 / 6 / 2 = 48
字体初始化
BootAnimation.cpp 中字体初始化入口是 initFont:
status_t BootAnimation::initFont(Font* font, const char* fallback) {
...
if (status == NO_ERROR) {
font->char_width = font->texture.w / FONT_NUM_COLS;
font->char_height = font->texture.h / FONT_NUM_ROWS / 2;
}
return status;
}
这里最关键的是 char_height 为什么要再除以 2. 源码注释里写得很直接:
// There are bold and regular rows
也就是说, 字体图集不是简单的 16 x 6, 而是每个字符所在的格子高度里同时塞了 regular 和 bold 两个版本.
时间格式
真正绘制时间的函数是 drawClock:
void BootAnimation::drawClock(const Font& font, const int xPos, const int yPos,
const Display& display) {
static constexpr char TIME_FORMAT_12[] = "%l:%M";
static constexpr char TIME_FORMAT_24[] = "%H:%M";
static constexpr int TIME_LENGTH = 6;
time_t rawtime;
time(&rawtime);
struct tm* timeInfo = localtime(&rawtime);
char timeBuff[TIME_LENGTH];
const char* timeFormat = mTimeFormat12Hour ? TIME_FORMAT_12 : TIME_FORMAT_24;
size_t length = strftime(timeBuff, TIME_LENGTH, timeFormat, timeInfo);
...
char* out = timeBuff[0] == ' ' ? &timeBuff[1] : &timeBuff[0];
int x = xPos;
int y = yPos;
drawText(out, font, false, &x, &y, display);
}
这里有两个细节:
- 24 小时制使用
%H:%M, 会得到类似08:30 - 12 小时制使用
%l:%M, 小于 10 点时前面会有空格, 所以后面通过timeBuff[0] == ' '把这个空格跳过
最终时间字符串会交给 drawText.
逐字绘制
drawText 的思路非常直接:
const int len = strlen(str);
const int strWidth = font.char_width * len;
先根据字符数量算出整段文字宽度. 如果 x 或 y 是居中标记, 就换算成屏幕坐标:
if (*x == TEXT_CENTER_VALUE) {
*x = (display.width - strWidth) / 2;
}
if (*y == TEXT_CENTER_VALUE) {
*y = (display.height - font.char_height) / 2;
}
如果坐标是负数, 则表示从屏幕右侧或底部反向定位:
if (*x < 0) {
*x = display.width + *x - strWidth;
}
if (*y < 0) {
*y = display.height + *y - font.char_height;
}
接下来就是每个字符的图集裁剪:
const int charPos = (c - FONT_BEGIN_CHAR);
const int row = charPos / FONT_NUM_COLS;
const int col = charPos % FONT_NUM_COLS;
float v0 = (row + (bold ? 0.5f : 0.0f)) / FONT_NUM_ROWS;
float u0 = ((float)col) / FONT_NUM_COLS;
float v1 = v0 + 1.0f / FONT_NUM_ROWS / 2;
float u1 = u0 + 1.0f / FONT_NUM_COLS;
glUniform4f(mTextCropAreaLocation, u0, v0, u1, v1);
drawTexturedQuad(*x, *y, font.char_width, font.char_height, display);
*x += font.char_width;
换句话说:
- 用 ASCII 码减去空格, 得到字符在图集里的序号
- 通过
/ 16和% 16算出行列 - regular 字体取当前行上半区
- bold 字体取当前行下半区
- 绘制一个字符后,
x += char_width, 继续绘制下一个字符
迁移到网页
在网页版本中, 我仍然使用 Canvas 2D 来复现这段逻辑. 对应 OpenGL 的 uCropArea, Canvas 可以直接用 drawImage 的 9 参数形式:
ctx.drawImage(
font,
sx,
sy,
charWidth,
charHeight,
drawX,
drawY,
charWidth * scale,
charHeight * scale
);
其中 sx 和 sy 就对应源码里的 u0/v0, 只是 Canvas 使用像素坐标而不是 0 到 1 的 UV 坐标.
为了方便观察, 我也加了 lil-gui:
system time: 使用当前系统时间text: 手动输入要绘制的字符串12h: 切换 12 小时制seconds: 是否显示秒bold: 切换到图集下半区的 bold 字符x/y: 调整绘制位置,0表示居中scale: 按整数倍放大或缩小字形
这里的 scale 我特意限制成整数倍. clock_font.png 本质上是位图字体, 如果用 1.4、1.5 这种非整数缩放, 浏览器会把源像素插值到目标像素上, 看起来就会发虚. 源码里的 OpenGL 使用 GL_NEAREST, 所以网页版本也通过整数缩放、整数坐标和 image-rendering: pixelated 来尽量保持同样的锐利边缘.
下面就是迁移后的实时效果:
::boot-clock::
Android 开机时间绘制复现
根据 BootAnimation.cpp 中 drawClock() / drawText() 的字体图集裁剪逻辑迁移