返回

安卓系统的开机动画是如何绘制时间

发布于:  at  08:12 上午
⏱️ 约 3114 字 • 预计阅读 8 分钟

Read in English

本文将介绍 Android BootAnimation 中 drawClock 和 drawText 如何通过字体图集裁剪绘制开机动画里的时间文本

前言

上一篇我研究了 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);
}

这里有两个细节:

  1. 24 小时制使用 %H:%M, 会得到类似 08:30
  2. 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;

换句话说:

  1. 用 ASCII 码减去空格, 得到字符在图集里的序号
  2. 通过 / 16 和 % 16 算出行列
  3. regular 字体取当前行上半区
  4. bold 字体取当前行下半区
  5. 绘制一个字符后, 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() 的字体图集裁剪逻辑迁移


在以下平台分享此文章:

评论


上一篇
PixelCompass:一个成本更低、支持网页配置的《我的世界》实体罗盘
下一篇
安卓开机动画 Logo 的闪烁效果是如何实现的?从 AOSP 源码到网页复现