Android 系统源码初步阅读之调色板 Palette 的使用与源码解读


基于 Android 12

一、Palette 是什么

(一)定义

A helper class to extract prominent colors from an image.
顾名思义,调色盘,从类注释中,我们可以轻松得知这是一个可以帮助我们从一张图片中提取突出颜色的类。

(二)可以提取的颜色

  1. 前置知识

色彩亮度,又称指色彩的明度。色彩明度是色彩的三要素(色相、明度和纯度)之一。不同颜色会有明暗的差异,相同颜色也有明暗深浅的变化。

  1. 系统内置
  • VIBRANT【充满活力的颜色,亮度适中】
  • LIGHT_VIBRANT【充满活力的颜色,亮度较高】
  • DARK_VIBRANT【充满活力的颜色,亮度较低】
  • MUTED【柔和的颜色,亮度适中】
  • LIGHT_MUTED【柔和的颜色,亮度较高】
  • DARK_MUTED【柔和的颜色,亮度较低】

(三)重要的几个类介绍

  • Palette 可以帮助我们从一张图片中提取突出颜色的类,称为调色板
  • Target 表示想要选择的颜色的类。 Palette 要提取出的突出颜色就是这个类设定的。这些颜色称为目标色,目标色不是拥有具体色值的某一个颜色,而是具有一些特征的颜色集合。
  • Swatch 表示从图像分析生成的色样,即样板色,同样的,样板色也不是一个具体的颜色,而是包含了主色,标题色和文本色的颜色集合。
  • 总的来说,就是有一个图片 Bitmap,我们想要提取的这张图片亮度适中的充满活力的颜色集合是 Target,成功提取出来的样板色就是 Swatch

二、Demo 学习

(一)效果

请添加图片描述

(二)实现

1. 首页顶部的颜色

  • 获取目标色为 VIBRANTLIGHT_MUTEDDARK_MUTED 的颜色样板
  • 获取样板中的主色 rgb,标题色 titleTextColor 和文本色 bodyTextColor
// 通过资源获取图片的 bitmap 形式
val bitmapRes = BitmapFactory.decodeResource(resources,it.characterInfo.imgResId)
// 解析图片
Palette.from(bitmapRes).generate { paletteOut ->
    paletteOut?.let { paletteIn ->
        val headerPicture = findViewById<CircleImageView>(R.id.civ_header_picture)
        headerPicture.setImageResource(it.characterInfo.imgResId)

        val nameTV = findViewById<TextView>(R.id.tv_name)
        nameTV.text = it.characterInfo.name
        val describeTV = findViewById<TextView>(R.id.tv_describe)
        describeTV.text = it.characterInfo.desc

		// 获取目标集为 VIBRANT、LIGHT_MUTED 和 DARK_MUTED 的颜色样板
		// 获取样板中的主色 rgb,标题色 titleTextColor 和文本色 bodyTextColor
        paletteIn.vibrantSwatch?.rgb?.let { rgb ->
            val bgView = findViewById<View>(R.id.view_bg_header_picture)
            bgView.background = ColorDrawable(rgb)
        }
        paletteIn.lightMutedSwatch?.titleTextColor?.let { textColor ->
            nameTV.setTextColor(textColor)
        }
        paletteIn.darkMutedSwatch?.bodyTextColor?.let { textColor ->
            describeTV.setTextColor(textColor)
        }
    }
}

2. 首页下方 item 中名称的背景颜色与文本颜色

  • 获取目标色为 VIBRANT 的颜色样板
  • 获取样板中的主色 rgb 和标题色 titleTextColor
// 通过资源获取图片的 bitmap 形式
val bitmap = BitmapFactory.decodeResource(resources,it.characterInfo.imgResId)
Palette.from(bitmap).generate { paletteOut ->
    paletteOut?.let { paletteIn ->
    	// 获取目标集为 VIBRANT 的颜色样板
		// 获取样板中的主色 rgb 和标题色 titleTextColor
        paletteIn.vibrantSwatch?.rgb?.let { rgb ->
            it.nameTV.background = ColorDrawable(rgb)
        }
        paletteIn.vibrantSwatch?.titleTextColor?.let { textColor ->
            it.nameTV.setTextColor(textColor)
        }
    }
}

3. 详情页

  • 详情页顶部和首页顶部一样,这里不再赘述
  • 下方列表列举了同一张图片分析不同目标色得到的各种样板色
  • 背景颜色为样板色的主色 rgb
  • 文本颜色为样板色的标题颜色 titleTextColor
// 详情页顶部和首页顶部一样,这里不再赘述
// 下方列表列举了同一张图片下分析不同目标色集合得到的各种样板色
// 背景颜色为样板色的主色
// 文本颜色为样板色的标题颜色
val bitmapRes = BitmapFactory.decodeResource(resources,characterInfo.imgResId)
Palette.from(bitmapRes).generate { paletteOut ->
    paletteOut?.let { paletteIn ->
        headerPictureIV.setImageResource(characterInfo.imgResId)
        nameTV.text = characterInfo.name
        describeTV.text = characterInfo.desc

        paletteIn.vibrantSwatch?.rgb?.let { rgb ->
            val bgView = findViewById<View>(R.id.view_bg_header_picture)
            bgView.background = ColorDrawable(rgb)
        }
        paletteIn.lightMutedSwatch?.titleTextColor?.let { textColor ->
            nameTV.setTextColor(textColor)
        }
        paletteIn.darkMutedSwatch?.bodyTextColor?.let { textColor ->
            describeTV.setTextColor(textColor)
        }

        paletteIn.vibrantSwatch?.rgb?.let { bgColor ->
            vibrantTV.setBackgroundColor(bgColor)
        }
        paletteIn.vibrantSwatch?.titleTextColor?.let { textColor ->
            vibrantTV.setTextColor(textColor)
        }

        paletteIn.lightVibrantSwatch?.rgb?.let { bgColor ->
            vibrantLightTV.setBackgroundColor(bgColor)
        }
        paletteIn.lightVibrantSwatch?.titleTextColor?.let { textColor ->
            vibrantLightTV.setTextColor(textColor)
        }

        paletteIn.darkVibrantSwatch?.rgb?.let { bgColor ->
            vibrantDarkTV.setBackgroundColor(bgColor)
        }
        paletteIn.darkVibrantSwatch?.titleTextColor?.let { textColor ->
            vibrantDarkTV.setTextColor(textColor)
        }

        paletteIn.mutedSwatch?.rgb?.let { bgColor ->
            mutedTV.setBackgroundColor(bgColor)
        }
        paletteIn.mutedSwatch?.titleTextColor?.let { textColor ->
            mutedTV.setTextColor(textColor)
        }

        paletteIn.lightMutedSwatch?.rgb?.let { bgColor ->
            mutedLightTV.setBackgroundColor(bgColor)
        }
        paletteIn.lightMutedSwatch?.titleTextColor?.let { textColor ->
            mutedLightTV.setTextColor(textColor)
        }

        paletteIn.darkMutedSwatch?.rgb?.let { bgColor ->
            mutedDarkTV.setBackgroundColor(bgColor)
        }
        paletteIn.darkMutedSwatch?.titleTextColor?.let { textColor ->
            mutedDarkTV.setTextColor(textColor)
        }

    }
}

(三)代码仓库

GitHub 仓库地址:https://github.com/NicholasHzf/PaletteLearningDemo

三、源码解读

前置知识:

HSL 是一种将 RGB 色彩模型中的点在圆柱坐标系中的表示法。这两种表示法试图做到比基于笛卡尔坐标系的几何结构 RGB 更加直观。是运用最广的颜色系统之一。

入口函数:Palette.from(xxx).generate{// …}

1. from(bitmapRes)

A.) public static Builder from(@NonNull Bitmap bitmap) 这个 from 方法比较好理解,就是使用建造者模式,通过图片的 bitmap 来构建用于产生调色板实例的建造者。

@NonNull
public static Builder from(@NonNull Bitmap bitmap) {
	return new Builder(bitmap);
}

B.) 这个方法就是简单的 new 了一个建造者出来。那么 Builder 的构造函数里面又发生了什么呢?我们继续往下看。

  • 添加系统默认的过滤器,这个过滤器的作用是:细粒度控制生成的调色板中哪些颜色的有效性
  • 设置图片的 bitmap
  • 重置样板色集合
  • 添加系统默认的六种目标色
public Builder(@NonNull Bitmap bitmap) {
	// ... 异常判断
	// 添加默认的过滤器
	mFilters.add(DEFAULT_FILTER);
	// 设置图片的 bitmap
    mBitmap = bitmap;
    // 重置样板色集合
    mSwatches = null;
	// 添加系统默认的六种目标色
    mTargets.add(Target.LIGHT_VIBRANT);
    mTargets.add(Target.VIBRANT);
    mTargets.add(Target.DARK_VIBRANT);
    mTargets.add(Target.LIGHT_MUTED);
    mTargets.add(Target.MUTED);
    mTargets.add(Target.DARK_MUTED);
}

2. generate{// …}

A.) 产生一个异步任务并使用特定参数执行它

@NonNull
public AsyncTask<Bitmap, Void, Palette> generate(@NonNull final PaletteAsyncListener listener) {
    // ... 异常处理
    // 产生一个异步任务并使用特定参数执行它
    return new AsyncTask<Bitmap, Void, Palette>() {
        @Override
        @Nullable
        protected Palette doInBackground(Bitmap... params) {
        	try {
        		// 真正的生成调色板的函数
            	return generate();
        	} catch (Exception e) {
            	Log.e(LOG_TAG, "Exception thrown during async generate", e);
            	return null;
        	}
    	}

    	@Override
    	protected void onPostExecute(@Nullable Palette colorExtractor) {
    		// 执行后的回调
            listener.onGenerated(colorExtractor);
        }
    }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, mBitmap);
}

B.) 真正的生成调色板的函数 generate()

@NonNull
public Palette generate() {
    // ...
    List<Swatch> swatches;
    if (mBitmap != null) {
        // 量化位图以减少颜色数量
        // 首先缩小位图
        final Bitmap bitmap = scaleBitmapDown(mBitmap);
        // ...
        final Rect region = mRegion;
        // 如果我们有一个缩放过的位图和选中的区域,那么我们要使得这两个部分相匹配
        if (bitmap != mBitmap && region != null) {
            // 计算缩放比例
            final double scale = bitmap.getWidth() / (double) mBitmap.getWidth();
            // 匹配区域
            region.left = (int) Math.floor(region.left * scale);
            region.top = (int) Math.floor(region.top * scale);
            region.right = Math.min((int) Math.ceil(region.right * scale),
                bitmap.getWidth());
            region.bottom = Math.min((int) Math.ceil(region.bottom * scale),
                bitmap.getHeight());
        }
        // 从位图中生成一个量化器
        final ColorCutQuantizer quantizer = new ColorCutQuantizer(getPixelsFromBitmap(bitmap),mMaxColors,mFilters.isEmpty() ? null : mFilters.toArray(new Filter[mFilters.size()]));
        // 如果上述过程中产生了新的位图,那么就回收它
        if (bitmap != mBitmap) {
            bitmap.recycle();
        }
        // 从生成的量化器获取量化后的样板色集合
        swatches = quantizer.getQuantizedColors();
        // ...
    } else if (mSwatches != null) {
        // 没有特定的 bitmap 但是有给定的样板色集合,那么我们直接使用它们
        swatches = mSwatches;
    } else {
        // ... 抛出异常
    }
    // 使用样板色集合和目标色集合创建调色板实例
    final Palette p = new Palette(swatches, mTargets);
    // 调色版创建
    p.generate();
    // ...
    return p;
}

C.) swatches = quantizer.getQuantizedColors() 从生成的量化器获取量化后的样板色集合,这里是直接返回结果,那么我们就要看这个结果是哪里得到的

List<Palette.Swatch> getQuantizedColors() {
	return mQuantizedColors;
}

D.) 查看 mQuantizedColors 的调用关系可以知道,是在量化器的构造函数中生成的,所以此刻需要看一下这个构造函数 ColorCutQuantizer(final int[] pixels, final int maxColors, final Palette.Filter[] filters)

  • 参数一:pixels 上面使用的 bitmap 的像素数据或者是 bitmap 指定区域的像素数据
  • 参数二:maxColors 调色板中应包含的最大颜色数
  • 参数三:filters 在量化阶段使用的一组过滤器

具体的量化过程和算法,我们不用太抠细节,感兴趣的小伙伴可以自行研究学习,这里不再详细解读,贴一下 ColorCutQuantizer 类的描述,可以看出是基于优化过的中位切割算法对颜色集合进行减色。

An color quantizer based on the Median-cut algorithm, but optimized for picking out distinct colors rather than representation colors. The color space is represented as a 3-dimensional cube with each dimension being an RGB component. The cube is then repeatedly divided until we have reduced the color space to the requested number of colors. An average color is then generated from each cube. What makes this different to median-cut is that median-cut divided cubes so that all of the cubes have roughly the same population, where this quantizer divides boxes based on their color volume. This means that the color space is divided into distinct colors, rather than representative colors.

重点关注一下我们的目标变量 mQuantizedColors

  • 本身颜色就少的情况下,直接使用就行,不需要减少
  • 颜色过多的时候就需要使用量化来减少颜色的数量
  • 不管是哪种形式,最后都是将颜色转为 RGB888,再调用样板色的构造函数获得样板色
ColorCutQuantizer(final int[] pixels, final int maxColors, final Palette.Filter[] filters) {
    // ...
    if (distinctColorCount <= maxColors) {
        // 本身颜色就少的情况下,直接使用就行,不需要减少
        mQuantizedColors = new ArrayList<>();
        for (int color : colors) {
            mQuantizedColors.add(new Palette.Swatch(approximateToRgb888(color), hist[color]));
        }
        // ...
    } else {
        // 颜色过多的时候就需要使用量化来减少颜色的数量
        mQuantizedColors = quantizePixels(maxColors);
        // ...
    }
}
private List<Palette.Swatch> quantizePixels(int maxColors) {
	// ... 算法相关内容,分割 box
    // 最后返回 boxes 的平均的颜色值
    return generateAverageColors(pq);
}
private List<Palette.Swatch> generateAverageColors(Collection<Vbox> vboxes) {
	ArrayList<Palette.Swatch> colors = new ArrayList<>(vboxes.size());
	// 循环 boxes 获取平均颜色
    for (Vbox vbox : vboxes) {
		Palette.Swatch swatch = vbox.getAverageColor();
        // ...
    }
    return colors;
}
final Palette.Swatch getAverageColor() {
    // ...
    return new Palette.Swatch(approximateToRgb888(redMean, greenMean, blueMean), totalPopulation);
}

E.) 那么我们的样板色的主色和标题色以及文本色是否是在构造样板色的时候就确定好的呢?看一下样板色的构造函数就清楚了!

public Swatch(@ColorInt int color, int population) {
	mRed = Color.red(color);
	mGreen = Color.green(color);
	mBlue = Color.blue(color);
	// 可以看的出来,主色在构造后就产生了
	mRgb = color;
	mPopulation = population;
}

主色确实在样板色对象构造后就确定了,那么标题色和文本色呢?我们再关注一下表示标题色和文本色的变量 mTitleTextColormBodyTextColor

@ColorInt
public int getTitleTextColor() {
	ensureTextColorsGenerated();
	return mTitleTextColor;
}
@ColorInt
public int getBodyTextColor() {
	ensureTextColorsGenerated();
	return mBodyTextColor;
}

可以看出,在获取这两种颜色的时候有一个确保目标颜色生成的函数,应该就是这个函数保证了标题色和文本色的生成。

private void ensureTextColorsGenerated() {
    // 如果产生了就不再重复产生
    if (!mGeneratedTextColors) {
        // 首先检查白色系,因为大多数颜色都是深色系的,那么使用白色系的文字的情况会更多
        // 计算可应用于前景的最小 alpha 值,以便与背景相比时至少具有 minContrastRatio 的对比度值
        // Color.WHITE 前景色 mRgb 背景色 MIN_CONTRAST_BODY_TEXT 最小对比度
        final int lightBodyAlpha = ColorUtils.calculateMinimumAlpha(
            Color.WHITE, mRgb, MIN_CONTRAST_BODY_TEXT);
        final int lightTitleAlpha = ColorUtils.calculateMinimumAlpha(
            Color.WHITE, mRgb, MIN_CONTRAST_TITLE_TEXT);
        // 如果白色系符合要求,则添加透明度,更新标题色和文本色并结束函数流程
        if (lightBodyAlpha != -1 && lightTitleAlpha != -1) {
            mBodyTextColor = ColorUtils.setAlphaComponent(Color.WHITE, lightBodyAlpha);
            mTitleTextColor = ColorUtils.setAlphaComponent(Color.WHITE, lightTitleAlpha);
            mGeneratedTextColors = true;
            return;
        }
        // 如果白色系不符合要求,则检查深色系
        final int darkBodyAlpha = ColorUtils.calculateMinimumAlpha(
            Color.BLACK, mRgb, MIN_CONTRAST_BODY_TEXT);
        final int darkTitleAlpha = ColorUtils.calculateMinimumAlpha(
            Color.BLACK, mRgb, MIN_CONTRAST_TITLE_TEXT);
        // 如果深色系符合要求,则添加透明度,更新标题色和文本色并结束函数流程
        if (darkBodyAlpha != -1 && darkTitleAlpha != -1) {
            mBodyTextColor = ColorUtils.setAlphaComponent(Color.BLACK, darkBodyAlpha);
            mTitleTextColor = ColorUtils.setAlphaComponent(Color.BLACK, darkTitleAlpha);
            mGeneratedTextColors = true;
            return;
        }

        // 如果都不符合要求,则使用不匹配的值,有异常崩溃的可能
        mBodyTextColor = lightBodyAlpha != -1
        ? ColorUtils.setAlphaComponent(Color.WHITE, lightBodyAlpha)
        : ColorUtils.setAlphaComponent(Color.BLACK, darkBodyAlpha);
        mTitleTextColor = lightTitleAlpha != -1
        ? ColorUtils.setAlphaComponent(Color.WHITE, lightTitleAlpha)
        : ColorUtils.setAlphaComponent(Color.BLACK, darkTitleAlpha);
        mGeneratedTextColors = true;
    }
}
  • 结论: 样板色的主色在构造函数执行时就确定了,而标题色和文本色在首次调用时确定,而且获取标题色和文本色时有异常崩溃的可能

F.) final Palette p = new Palette(swatches, mTargets) 有了目标色集合和样板色集合,那是时候创建我们的调色板了!

Palette(List<Swatch> swatches, List<Target> targets) {
	mSwatches = swatches;
	mTargets = targets;

	mUsedColors = new SparseBooleanArray();
	mSelectedSwatches = new ArrayMap<>();

	mDominantSwatch = findDominantSwatch();
}

构造函数看起来没什么稀奇,就是把样板色集合和目标色集合赋值了一下,那么我们给定目标色时,系统是怎么确定样板色的呢,我们继续看一下调色板的 generate 方法,毕竟从执行时机(创建调色版实例后立刻调用)和函数名称(产生/生成)来看,这是最有可能的地方

G.) p.generate() 确定目标色的样板色

void generate() {
    // 生成对应的分数,再用键值对的形式把目标色和样板色一一对应存储起来
    for (int i = 0, count = mTargets.size(); i < count; i++) {
        final Target target = mTargets.get(i);
        target.normalizeWeights();
        mSelectedSwatches.put(target, generateScoredTarget(target));
    }
    // 清除“用过的”颜色集合,现在已经没有用了,它的作用是用于选择正确的样板色
    mUsedColors.clear();
}

获取根据目标色获取的最高分数的样板色。“用过的”颜色列表,目前看来还不知道有什么用,继续看下去

@Nullable
private Swatch generateScoredTarget(final Target target) {
	// 获取根据目标色获取的最高分数的样板色
	final Swatch maxScoreSwatch = getMaxScoredSwatchForTarget(target);
	// 如果样板色有效并且是目标色独有的,那么我们就把这个样板色的主色加入“用过的”颜色列表
	if (maxScoreSwatch != null && target.isExclusive()) {
		mUsedColors.append(maxScoreSwatch.getRgb(), true);
	}
	return maxScoreSwatch;
}

遍历样板色并计算相关得分,并从中获取得分最高的样板色,这里有两个重要函数,一个函数是用于判断是否需要计算得分,另一个是用于计算得分的

  • shouldBeScoredForTarget(swatch, target) 判断是否需要计算得分
  • generateScore(swatch, target) 计算得分
@Nullable
private Swatch getMaxScoredSwatchForTarget(final Target target) {
    float maxScore = 0;
    Swatch maxScoreSwatch = null;
    // 遍历样板色并计算相关得分
    for (int i = 0, count = mSwatches.size(); i < count; i++) {
        final Swatch swatch = mSwatches.get(i);
        if (shouldBeScoredForTarget(swatch, target)) {
            final float score = generateScore(swatch, target);
            if (maxScoreSwatch == null || score > maxScore) {
                maxScoreSwatch = swatch;
                maxScore = score;
            }
        }
    }
    return maxScoreSwatch;
}

判断是否需要计算得分

  • hsl 值是在正确的范围内并且这个颜色没有被使用
  • “用过的”颜色列表,原来是为了保证目标色对应的样板色是唯一的
private boolean shouldBeScoredForTarget(final Swatch swatch, final Target target) {
	// 检查 hsl 值是否在正确的范围内,以及这个颜色是否已经被使用了
	final float hsl[] = swatch.getHsl();
	return hsl[1] >= target.getMinimumSaturation() && hsl[1] <= target.getMaximumSaturation()
                && hsl[2] >= target.getMinimumLightness() && hsl[2] <= target.getMaximumLightness()
                && !mUsedColors.get(swatch.getRgb());
}

终于到了最后一步,计算目标色对应的样板色的分数!

private float generateScore(Swatch swatch, Target target) {
    final float[] hsl = swatch.getHsl();
	// 这里有三个分数,分别是饱和度分数,亮度分数,受欢迎程度的分数
    float saturationScore = 0;
    float luminanceScore = 0;
    float populationScore = 0;
	// 获取样板色集合中最大的受欢迎程度的分数,下面会用它来计算百分比
    final int maxPopulation = mDominantSwatch != null ? mDominantSwatch.getPopulation() : 1;
	// 如果目标色的饱和度占比大于 0
	// 则计算分数
    if (target.getSaturationWeight() > 0) {
        saturationScore = target.getSaturationWeight()
        * (1f - Math.abs(hsl[1] - target.getTargetSaturation()));
    }
    // 如果目标色的亮度占比大于 0
	// 则计算分数
    if (target.getLightnessWeight() > 0) {
        luminanceScore = target.getLightnessWeight()
        * (1f - Math.abs(hsl[2] - target.getTargetLightness()));
    }
    // 如果目标色的受欢迎程度分数占比大于 0
	// 则计算分数
    if (target.getPopulationWeight() > 0) {
        populationScore = target.getPopulationWeight()
        * (swatch.getPopulation() / (float) maxPopulation);
    }
	// 三种分数相加即为最终得分
    return saturationScore + luminanceScore + populationScore;
}

上述代码中的 “受欢迎程度分数” 最早可以追溯到 量化减色获取样板色集合的阶段,那个时候就确定了各个样板色的“受欢迎程度分数”,然后在调色板构造函数执行时,findDominantSwatch() 函数则确定了样板色集合中“受欢迎程度分数”最高的样板色。

3. 总结

至此,我们将输入位图到获得调色盘的整个流程走了一遍,下面梳理一下简要过程:

  • 传入 bitmap 获取调色盘建造者的对象
  • 使用建造者对象建造调色盘,建造者对象在构造时添加了系统默认的六种目标色
    • 对 bitmap 进行量化减色
    • 从生成的量化器中获取量化后的样板色集合
    • 构建样板色的同时确定了样板色的主色,而样板色的标题色和文本色则是在第一次 get 的时候确定
    • 传入目标色集合和样板色集合构建调色盘
    • 依据目标色计算并获取得分最高的样板色,将其一一对应存入键值对结构

源码学习,路漫漫其修远兮,保持本心,好好学习!如有错误,烦请指正!感谢阅读!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值