AB
AiBoss
Tutorials

Palette

Tutorials

用 Palette 从图片中提取主题色并应用到界面

Palette 是 Android 上从 Bitmap 中提取主体颜色的工具类,能给出柔和、生动两类共六种风格色。本文讲清它的三步用法、同步与异步两种取色方式、六种取色接口的默认值处理,并拆解它背后的颜色量化流程:RGB888 压缩到 RGB555 直方图、Filter 过滤、中位切分法切分 VBox,最后说明常见坑与参数取舍。

Palette 是 Android 平台上一个从图片里提取主体颜色的工具类。给它一张 Bitmap,它会统计出这张图里最有代表性的若干种颜色,并把这些颜色归类成「柔和」和「生动」两种气质、各三种明暗层次,共六种风格色。把这些颜色用到背景、标题文字、图标着色上,界面既能保持色彩丰富,又不会因为随手取色而显得杂乱。它适合需要在界面上跟随内容图片自动配色的场景,比如音乐播放页根据专辑封面染色、资讯卡片根据配图生成背景渐变、详情页根据头图决定强调色。如果你只是想快速验证一张图能取出什么颜色,可以配合 Palette 这类在线取色工具先看一眼效果,再决定代码里怎么接。

需要先说明一点:Palette 取到的颜色不是「一种」,而是一组样本。它不保证六种风格色都取得到,取不到时由调用方传入的默认色兜底。理解这一点,后面的用法和原理才顺得下来。

准备工作

在动手写代码之前,先把环境和概念对齐。

环境与依赖

  • Android 工程,能正常构建并运行。
  • Palette 属于 AndroidX 体系下的调色板库,需要在模块的构建脚本里引入对应依赖。具体坐标与版本号请以官方当前发布信息为准,不同版本之间 API 可能有细微差异。
  • 一张用于测试的图片资源,放在 res/drawable 下,或者从相册、网络拿到 Bitmap 后在内存中使用。

几个必须先弄清的概念

RGB 彩色空间:把颜色抽象到空间模型里,以 R、G、B 三个分量作为三个坐标轴,空间中的每个点对应一种颜色。这个空间能表示所有 RGB 颜色,但并不等于自然界的全部颜色。RGB 还按精度分多种格式,常见的有 RGB888、ARGB8888、RGB565、RGB555 等,位数越低表示的颜色越少。

HSL:工业界的一种颜色标准,用色相(H)、饱和度(S)、明度(L)三个通道来描述颜色,和 RGB 之间可以互相转换。Palette 的过滤器就是基于 HSL 做判断的,所以理解 HSL 有助于写出符合预期的 Filter。

VBox:一个代表 RGB 彩色空间(或子彩色空间)的对象,它的三条边分别对应红、绿、蓝三个分量,因此它的体积代表这块彩色空间的色域大小。中位切分法切的就是 VBox。

Swatch:Palette 输出的颜色样本,除了颜色值本身,还带有该颜色在图中出现的像素数量等信息。

操作步骤

第一步:用 Builder 配置参数并生成 Palette

Palette 采用建造者模式实例化。典型写法是把 Bitmap 交给 Palette.from(),再按需链式追加配置,最后调用 generate():

Palette p = Palette.from(bitmap)
        .addFilter(filter)          // 可选,按 HSL 规则过滤颜色
        .maximumColorCount(20)      // 可选,颜色种数临界值
        .resizeBitmapSize(480)      // 可选,压缩后的位图尺寸
        .clearFilters()             // 可选,清除已添加的过滤器
        .generate();                // 生成 Palette 实例

逐个参数说明:

  • addFilter(Filter):添加一个颜色过滤器,按 HSL 规则剔除不需要参与统计的颜色。可以不设置,库内有默认过滤器。
  • maximumColorCount(int):设置颜色种数的临界值。当图片中有效颜色块的数量小于这个值时,直接把这些颜色作为最终统计到的样本,不再做切分。可以不设置,有默认值。
  • resizeBitmapSize(int):Bitmap 放进 Palette 时会先压缩,以提高计算速度,这里设置压缩后的尺寸。可以不设置,有默认值。
  • clearFilters():清除颜色过滤器。可以不调用。

注意 maximumColorCount 不是「最多返回几种颜色」这么简单,它同时决定了量化流程走哪条分支:颜色本来就少,直接全收;颜色多,才进入中位切分。

第二步:选择同步或异步生成

generate() 有两个重载:

  • 无参版本:直接返回 Palette,是同步方法,会在当前线程完成全部统计计算。
  • 有参版本:返回一个异步任务,通过传入的回调拿到结果。

统计图片颜色是比较耗时的操作,颜色种类越多耗时越长。因此在主线程上直接调用无参版本有卡顿风险,常规做法是放到后台线程,或者直接用带回调的异步版本。异步版本的写法大致如下:

Palette.from(bitmap)
        .maximumColorCount(16)
        .resizeBitmapSize(480)
        .generate(new Palette.PaletteAsyncListener() {
            @Override
            public void onGenerated(Palette palette) {
                // 回调线程不保证是主线程,更新 UI 前请自行切回主线程
                int color = palette.getVibrantColor(defaultColor);
            }
        });

回调在哪个线程执行、是否需要手动切回主线程,取决于所用库版本与调用方式,接入时请以官方当前文档为准。

第三步:从 Palette 中取颜色

Palette 提供六种风格的颜色,柔和三种、生动三种:

p.getDarkMutedColor(defaultColor);
p.getDarkVibrantColor(defaultColor);
p.getLightMutedColor(defaultColor);
p.getLightVibrantColor(defaultColor);
p.getMutedColor(defaultColor);
p.getVibrantColor(defaultColor);

这六个方法都要求传入一个默认色。原因是:并不一定每种风格都采集到了合适的颜色,取不到时方法返回你传入的默认值。所以调用方必须自己准备兜底色,不能假设一定有值。

实践上常见的组合是:用 getVibrantColor 做强调色(按钮、选中态),用 getMutedColor 或 getDarkMutedColor 做背景,用 getLightVibrantColor 做浅色背景上的文字。具体选哪几个,取决于你的界面层级和对比度要求。

一个完整示例

下面是一个从资源图片取色、并把结果应用到背景与文字上的最小可运行例子。

// 1. 准备 Bitmap
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.cover);

// 2. 兜底色,取不到时使用
int defaultBackground = 0xFFEEEEEE;
int defaultTitle = 0xFF333333;

// 3. 异步生成 Palette
Palette.from(bitmap)
        .maximumColorCount(16)
        .resizeBitmapSize(480)
        .generate(new Palette.PaletteAsyncListener() {
            @Override
            public void onGenerated(Palette palette) {
                // 4. 取色,全部带默认值
                int background = palette.getMutedColor(defaultBackground);
                int titleColor = palette.getDarkVibrantColor(defaultTitle);
                int accent = palette.getVibrantColor(defaultTitle);

                // 5. 应用到界面(切回主线程后执行)
                rootView.setBackgroundColor(background);
                titleView.setTextColor(titleColor);
                accentView.setBackgroundColor(accent);
            }
        });

如果图片本身颜色很少,比如纯色图标或双色插画,maximumColorCount 设成 16 时有效颜色数会小于这个值,Palette 直接把现有颜色作为样本返回,不再切分。这种情况下六种风格色可能大量落空,兜底色会被频繁用到,这是正常现象,不是代码写错了。

反过来,如果图片是照片、色彩极其丰富,切分过程会一直进行到 VBox 数量等于 maximumColorCount 为止,耗时随颜色种类增加而上升。此时把 resizeBitmapSize 调小能明显减少计算量。

实现原理

理解内部流程,才能解释为什么参数要这么设、为什么某些图取不到色。整个取色发生在 generate() 里,分两大阶段:先量化统计颜色,再由 Generator 筛选出六种风格色。

阶段一:压缩图片

首先把图片压缩到 resizeBitmapSize(int) 指定的尺寸,或者默认尺寸,以此减少参与计算的像素数量。这一步是纯性能优化,对最终结果影响有限。

阶段二:ColorCutQuantizer 采集颜色

压缩后的像素交给 ColorCutQuantizer,它负责采集满足要求的颜色。核心步骤如下。

1. 建立颜色直方图。 创建一个长度为 2 的 15 次方的数组作为直方图,横坐标代表颜色,纵坐标代表该颜色出现的次数。之所以是 2 的 15 次方,是因为颜色被量化成 RGB555,三个分量各 5 bit。

2. 把 RGB888 量化成 RGB555。 遍历每个像素,把 8 bit 的分量压缩成 5 bit,取原值的高位,再拼成一个整数作为直方图索引,同时把该位置的计数加一。量化会损失精度,但对最终取色结果影响很小,换来的是统计规模从 2 的 24 次方降到 2 的 15 次方,效率提升显著。核心逻辑相当于:

private static int quantizeFromRgb888(int color) {
    int r = modifyWordWidth(Color.red(color), 8, QUANTIZE_WORD_WIDTH);
    int g = modifyWordWidth(Color.green(color), 8, QUANTIZE_WORD_WIDTH);
    int b = modifyWordWidth(Color.blue(color), 8, QUANTIZE_WORD_WIDTH);
    return r << (QUANTIZE_WORD_WIDTH + QUANTIZE_WORD_WIDTH)
            | g << QUANTIZE_WORD_WIDTH
            | b;
}

3. 按 Filter 过滤。 遍历直方图,凡是计数大于 0 且被 shouldIgnoreColor() 判定为应忽略的颜色,就把它的计数置为 0。这一步同时统计出剩余的有效颜色种数 distinctColorCount。

4. 只保留计数大于 0 的颜色。 把有效颜色复制到一个新数组里,形成精简后的颜色集合。

5. 分支处理。 如果有效颜色种数小于等于 maxColors,直接把每个颜色包装成 Palette.Swatch 返回,不做切分;否则调用 quantizePixels(maxColors) 走中位切分。

阶段三:中位切分法

中位切分法是一种颜色量化算法,思路是对彩色空间 VBox 反复切分,直到盒子数量达到目标值。先看一个直观例子:假设当前有 VBox0、VBox1、VBox2、VBox3 四个彩色空间,VBox0 是另外三个的并集,三者互斥,各自有自己的颜色数量 P。选 VBox0 较长的那条边作为切分方向,把 VBox1、VBox2、VBox3 沿该方向排序,按顺序累加它们的 P,当累加值大于等于 VBox0 的 P 时,当前位置就是切分点。据此把 VBox0 切成 VBox01 和 VBox02,原来的三个盒子分别落入这两个新盒子之中。接着从新盒子里挑 P 值较大的那个,重复上述过程。

源码里的实现用优先队列完成:

private List<Palette.Swatch> quantizePixels(int maxColors) {
    // 按体积降序排序的优先队列,保证每次都切最大的盒子
    final PriorityQueue<Vbox> pq = new PriorityQueue<>(maxColors, VBOX_COMPARATOR_VOLUME);
    // 初始放入一个包含全部颜色的盒子
    pq.offer(new Vbox(0, mColors.length - 1));
    splitBoxes(pq, maxColors);
    return generateAverageColors(pq);
}

体积比较器的规则是体积大的排在前面:

private static final Comparator<Vbox> VBOX_COMPARATOR_VOLUME = new Comparator<Vbox>() {
    @Override
    public int compare(Vbox lhs, Vbox rhs) {
        return rhs.getVolume() - lhs.getVolume();
    }
};

切分循环的逻辑是:只要队列大小还小于 maxSize,就取出体积最大的盒子;如果它还能继续切,就切一刀,把切出来的新盒子放回队列,再把原盒子也放回队列;如果已经不能切,说明所有盒子都切不动了,直接返回。

private void splitBoxes(final PriorityQueue<Vbox> queue, final int maxSize) {
    while (queue.size() < maxSize) {
        final Vbox vbox = queue.poll();
        if (vbox != null && vbox.canSplit()) {
            queue.offer(vbox.splitBox());
            queue.offer(vbox);
        } else {
            return;
        }
    }
}

最后,对队列里每个盒子求平均色,生成 Swatch。注意这里还要再过滤一次:因为是对一个盒子取平均,平均出来的颜色仍可能落在不该保留的范围内,所以 generateAverageColors 里会再调用一次 shouldIgnoreColor() 做二次校验,通过后才加入结果列表。

需要说明的是,mColors 里的每个颜色值和它在 mHistogram 中的计数组合起来,在概念上就代表一个 VBox,代码并不会为每个颜色都创建 VBox 实例,只在需要切分时才构造。

阶段四:Generator 筛选风格色

量化得到的样本交给 Palette 内部的 Generator,由它按规则筛选出 Muted、Vibrant 等六种风格色。这就是为什么最终接口是六个 getXxxColor(defaultColor),而不是直接返回一个颜色列表——风格分类是在这一层完成的。

注意事项

  • 六种风格色不保证齐全。 每个取色方法都必须传默认色,取不到时返回默认值。不要写死假设某种风格一定存在。
  • 同步生成会阻塞调用线程。 无参 generate() 在当前线程完成全部计算,图片颜色越丰富耗时越长,放在主线程有卡顿风险。
  • 颜色种数影响耗时。 maximumColorCount 越大,切分越久。按界面实际需要设定,不要盲目调高。
  • 压缩尺寸影响精度与速度。 resizeBitmapSize 越小越快,但过小会丢失细节,取色结果可能偏离原图观感。
  • 量化本身有精度损失。 统计阶段把 8 bit 分量压到 5 bit,这是设计上的取舍,对结果影响很小,但如果对颜色精确度有严格要求,需要自行评估。
  • 过滤器会二次生效。 过滤不仅在直方图阶段执行,在盒子求平均后还会再执行一次,所以自定义 Filter 的判定逻辑要能接受被调用多次。
  • 取到的颜色不等于可直接使用。 背景色与文字色之间还需要自行校验对比度,Palette 只负责给出候选色,不负责可读性。
  • 版本差异。 依赖坐标、API 细节、回调线程行为等可能随版本变化,接入前请以官方当前发布信息为准。