Android 12 Widget 自动变色原理分析

众所周知,Android 12 预览版计划从 2021 年 2 月开始启动,到目前为止,已经推出Beta 4版本,虽然还没有推出最终正式版,但我们做为Android开发者,早就摩拳擦掌,期待着Android 12给我们带来新的惊喜。

而我们在Android 官网上,也确实发现一些与我们相关的新的特性,比如:Widget方面的改进,因为篇幅有限,这次我们只关注动态变色部分。

一、应用动态颜色

1.1、官网介绍

在 Android 12 中,微件可以为按钮、背景及其他组件使用设备主题颜色。这样可使过渡更流畅,而且还能在不同的微件之间保持一致。

在以下示例中,设备主题颜色为“呈褐色”,这使得强调色和微件背景进行调整。为此,您可以使用系统的默认主题 (@android:style/Theme.DeviceDefault.DayNight) 及其颜色属性。一些常用的颜色属性如下:

  • ?android:attr/colorAccent
  • ?android:attr/colorBackground
  • ?android:attr/textColorPrimary 和 ?android:attr/textColorSecondary
采用浅色主题的微件
采用深色主题的微件

按照Android官方说法,对于插件提供者来说,要想实现随壁纸颜色而改变只要使用Material相关的主题和颜色即可。

1.2、更多的动态效果

在 Pixel 设备上运行最新的 Android 12 Beta 5 版本后,您现在可以将 Material You Clock 小部件添加到主屏幕。

M3.png

您可以在“时钟”部分中点击并按住模拟时钟小部件,然后将其拖动到主屏幕的所需部分。

M4.png

当你这样做时,你应该看到新的模拟时钟小部件以类似于扇贝的形状出现在你的主屏幕上。禁用深色主题后,小部件将具有较浅的背景,深色以指示时针和分针,并用圆圈表示“秒”针。

M5.png

当您设备的深色主题启用时,模拟时钟将采用浅色指针占据深色背景。

M6.png

1.3、为什么 Android 12 上的时钟小部件会改变颜色?

默认情况下,Android 12 上的新时钟小部件会应用您在“壁纸和样式”应用中选择的壁纸颜色。这与应用于系统 UI 的其他元素(如设置、通知、快速设置和其他应用程序)的颜色集相同。

在我们的测试中,我们注意到时钟小部件不一定应用您在“壁纸和样式”中选择的颜色,而是在您将其拖到壁纸周围时更改其颜色。这是因为时钟小部件旨在根据放置墙纸的位置采用主题颜色。在下面的录屏中,您会注意到时钟小部件的颜色随着它从一个位置拖动到一个位置而发生变化。另一个。

M7.gif

小部件首先在以橙色突出显示的区域上方显示为灰白色背景。当时钟进一步向下移动到更关注光谱绿色侧的区域时,此背景会变为较浅的绿色色调。

二、原理分析

虽然Pixel Launcher的源码我们看不了,但是根据经验,实现上面的效果,应该是Launcher在拖放Widget时,根据Widget的位置,调用相关取色逻辑,得到相应的颜色之后,对Widget的颜色进行修改。

这里我们先上一个流程图,让大家有个印象:


时序.png

2.1、Widget位置拖动

首先,在这里分享一个小技巧,那就是可以通过Debug的方式快速定位调用栈,不过这也考验你对Android 整个框架和SDK API的熟悉程度,才能迅速的判断出要断点在什么地方。

像我们这里,因为主要是对Widget修改,那我们就知道Widget相关的承载类是AppWidgetHostView,而我们的Launcher用了一个LauncherAppWidgetHostView去继承AppWidgetHostView。

而我们LauncherAppWidgetHostView本质上是一个View,对拖动事件的处理入口,就在handleDrag中:


M8.png

此时我们把断点放置在此处,拖动插件,就可以看到是从什么地方调用过来的了


M10.jpg

好了,废话不多说,回归正题!

拖动的时候,会调用到updateColorExtraction中

 private void updateColorExtraction(Rect rectInDragLayer, int pageId) {
        if (!mEnableColorExtraction) return;
        //步骤1.通过坐标中获取一个矩形,保存到mTempRectF中
        mColorExtractor.getExtractedRectForViewRect(mLauncher, pageId, rectInDragLayer, mTempRectF);
        
        onColorsChanged(null, null);
        if (mTempRectF.isEmpty()) {
            return;
        }
        //步骤2.如果有发生位置移动,才进入里面的逻辑
        if (!isSameLocation(mTempRectF, mLastLocationRegistered, /* epsilon= */ 1e-6f)) {
            //这个mLastLocationRegistered其实就是最近一次移动的矩形,这里要将它移除掉,因为要添加新的了
            if (mLastLocationRegistered != null) {
                mColorExtractor.removeLocations();
            }
            mLastLocationRegistered = new RectF(mTempRectF);
            //步骤3.使用新的位置去获取颜色
            mColorExtractor.addLocation(List.of(mLastLocationRegistered));
        }
    }

上面这段代码,有三个步骤比较关键,但是1跟2,就是去获取当前Widget所在的RectF,判断是否有发生移动而已,还是比较容易理解的,我们就不作展开了,直接看步骤3.

[LocalWallpaperColorsExtractor.java]

public void addLocation(List list) {
    ...
    wallpaperManager.addOnColorsChangedListener(this, list);
    ...
}

这里其实是往WallpaperManager注册了一个接口,这第一个接口跟第二、三接口还是有点不同的,是在Android 12才新增的,主要用于区域取色。


M9.png

2.2、通过WallpaperManager提取颜色

这里因为涉及到很多壁纸服务的东西,所以我们还是稍微回顾一下壁纸相关的内容:

2.2.1、壁纸服务概述

在Android中,壁纸分为静态与动态两种。静态壁纸是一张图片,而动态壁纸则以动画为表现形式,或者可以对用户的操作作出反应。这两种形式看似差异很大,其实二者的本质是统一的。它们都以一个Service的形式运行在系统后台,并在一个类型为TYPE_WALLPAPER的窗口上绘制内容。进一步讲,静态壁纸是一种特殊的动态壁纸,它仅在窗口上渲染一张图片,并且不会对用户的操作作出反应。

Android壁纸的实现与管理分为三个层次:

  • WallpaperService与Engine。同SystemUI一样,壁纸运行在一个Android服务之中,这个服务的名字叫做WallpaperService。当用户选择了一个壁纸之后,此壁纸所对应的WallpaperService便会启动并开始进行壁纸的绘制工作,因此继承并定制WallpaperService是开发者进行壁纸开发的第一步。Engine是WallpaperService中的一个内部类,实现了壁纸窗口的创建以及Surface的维护工作。另外,Engine提供了可供子类重写的一系列回调,用于通知壁纸开发者关于壁纸的生命周期、Surface状态的变化以及对用户的输入事件进行响应。可以说,Engine类是壁纸实现的核心所在。壁纸开发者需要继承Engine类,并重写其提供的回调以完成壁纸的开发。这一层次的内容主要体现了壁纸的实现原理。

  • WallpaperManagerService,这个系统服务用于管理壁纸的运行与切换,并通过WallpaperManager类向外界提供操作壁纸的接口。当通过WallpaperManagaer的接口进行壁纸的切换时,WallpaperManagerService会取消当前壁纸的WallpaperService的绑定,并启动新壁纸的WallpaperService。另外,Engine类进行窗口创建时所使用的窗口令牌也是由WallpaperManagerService提供的。这一层次主要体现了Android对壁纸的管理方式。

  • WindowManagerService,用于计算壁纸窗口的Z序、可见性以及为壁纸应用窗口动画。壁纸窗口(TYPE_WALLPAPER)的Z序计算不同于其他类型的窗口。其他窗口依照其类型会有固定的mBaseLayer以及mSubLayer,并结合它们所属的Activity的顺序或创建顺序进行Z序的计算,因此这些窗口的Z序相对固定。而壁纸窗口则不然,它的Z序会根据FLAG_SHOW_WALLPAPER标记在其它窗口的LayoutParams.flags中的存在情况而不断地被调整。这一层次主要体现了Android对壁纸窗口的管理方式。

2.2.2、向WallpaperManager注册颜色获取接口

好了,现在我们回到WallpaperManager的addOnColorsChangedListener接口中,
具体内容如下:

/**
 * @hide
 */
public void addOnColorsChangedListener(@NonNull LocalWallpaperColorConsumer callback,
    List<RectF> regions) throws IllegalArgumentException {
    for (RectF region : regions) {
        if (!LOCAL_COLOR_BOUNDS.contains(region)) {
           throw new IllegalArgumentException("Regions must be within bounds "
                    + LOCAL_COLOR_BOUNDS);
        }
    }
    sGlobals.addOnColorsChangedListener(callback, regions, FLAG_SYSTEM,
                                                 mContext.getUserId(), mContext.getDisplayId());
}

我们看WallpaperManager.Global的实现

public void addOnColorsChangedListener(@NonNull LocalWallpaperColorConsumer callback,
                @NonNull List<RectF> regions, int which, int userId, int displayId) {
    for (RectF area: regions) {
        ArraySet<LocalWallpaperColorConsumer> callbacks = mLocalColorAreas.get(area);
        if (callbacks == null) {
            callbacks = new ArraySet<>();
            mLocalColorAreas.put(area, callbacks);
        }
        callbacks.add(callback);
    }
    try {
        //关键点
        mService.addOnLocalColorsChangedListener(mLocalColorCallback , regions, which,
                                                         userId, displayId);
    } catch (RemoteException e) {
        // Can't get colors, connection lost.
        Log.e(TAG, "Can't register for local color updates", e);
    }
}

从上面的代码可以看出,WallpaperManager是按区域注册listener,同个区域可以注册多个,主要是为了给多个应用同时使用。

接下来我们转到WallpaperManagerService

[WallpaperManagerService.java]

 @Override
    public void addOnLocalColorsChangedListener(@NonNull ILocalWallpaperColorConsumer callback,
            @NonNull List<RectF> regions, int which, int userId, int displayId)
            throws RemoteException {
       ...
        //步骤1
        IWallpaperEngine engine = getEngine(which, userId, displayId);
        if (engine == null) return;
        ArrayList<RectF> validAreas = new ArrayList<>(regions.size());
        synchronized (mLock) {
            ArraySet<RectF> areas = mLocalColorCallbackAreas.get(callback);
            if (areas == null) areas = new ArraySet<>(regions.size());
            areas.addAll(regions);
            mLocalColorCallbackAreas.put(callback.asBinder(), areas);
        }
        for (int i = 0; i < regions.size(); i++) {
            if (!LOCAL_COLOR_BOUNDS.contains(regions.get(i))) {
                continue;
            }
            RemoteCallbackList callbacks;
            synchronized (mLock) {
                callbacks = mLocalColorAreaCallbacks.get(
                        regions.get(i));
                if (callbacks == null) {
                    callbacks = new RemoteCallbackList();
                    mLocalColorAreaCallbacks.put(regions.get(i), callbacks);
                }
                mLocalColorCallbackDisplayId.put(callback.asBinder(), displayId);
                ArraySet<RectF> displayAreas = mLocalColorDisplayIdAreas.get(displayId);
                if (displayAreas == null) {
                    displayAreas = new ArraySet<>(1);
                    mLocalColorDisplayIdAreas.put(displayId, displayAreas);
                }
                displayAreas.add(regions.get(i));
            }
            validAreas.add(regions.get(i));
            callbacks.register(callback);
        }
        //步骤2
        engine.addLocalColorsAreas(validAreas);
    }
    

这里主要关注一下步骤1、2,如何获取当前壁纸的Engine,然后将关注的区域以List<RectF>类型传过去。

这里画了一个草图,大致能说明WallpapaerService跟当前壁纸的关系

M11.png

本节主要探讨静态壁纸ImageWallpaper对取色的实现,动态壁纸取色是需要壁纸提供方实现取色逻辑的。

[ImageWallpaper.java]

@Override
public void addLocalColorsAreas(@NonNull List<RectF> regions) {
    ...
    Bitmap bitmap = mMiniBitmap;
    if (bitmap == null) {
        mLocalColorsToAdd.addAll(regions);
    } else {
        //关键点
        computeAndNotifyLocalColors(regions, bitmap);
    }
    
}

@Override
public void removeLocalColorsAreas(@NonNull List<RectF> regions) {
    mWorker.getThreadHandler().post(() -> {
        mColorAreas.removeAll(regions);
        mLocalColorsToAdd.removeAll(regions);
        if (mColorAreas.size() + mLocalColorsToAdd.size() == 0) {
            setOffsetNotificationsEnabled(false);
        }
    });
}

2.2.3、根据区域计算颜色

调用computeAndNotifyLocalColors去计算颜色

private void computeAndNotifyLocalColors(@NonNull List<RectF> regions, Bitmap b) {
    //步骤1
    List<WallpaperColors> colors = getLocalWallpaperColors(regions, b);
    mColorAreas.addAll(regions);
    try {
        //步骤2
        notifyLocalColorsChanged(regions, colors);
    } catch (RuntimeException e) {
        Log.e(TAG, e.getMessage(), e);
    }
}

上述方法中有两个关键步骤:

步骤1:根据区域计算出颜色,得到一个WallpaperColors列表

private List<WallpaperColors> getLocalWallpaperColors(@NonNull List<RectF> areas,
                Bitmap b) {
    List<WallpaperColors> colors = new ArrayList<>(areas.size());
    updateShift();
    for (int i = 0; i < areas.size(); i++) {
        RectF area = pageToImgRect(areas.get(i));
        if (area == null || !LOCAL_COLOR_BOUNDS.contains(area)) {
            colors.add(null);
            continue;
        }
        Rect subImage = new Rect(
            (int) Math.floor(area.left * b.getWidth()),
            (int) Math.floor(area.top * b.getHeight()),
            (int) Math.ceil(area.right * b.getWidth()),
            (int) Math.ceil(area.bottom * b.getHeight()));
        if (subImage.isEmpty()) {
            // Do not notify client. treat it as too small to sample
            colors.add(null);
            continue;
        }
        //得到一个符合大小的Bitmap
        Bitmap colorImg = Bitmap.createBitmap(b,
                subImage.left, subImage.top, subImage.width(), subImage.height());
        //关键点,通过WallpaperColors提取出颜色
        WallpaperColors color = WallpaperColors.fromBitmap(colorImg);
        colors.add(color);
    }
    return colors;
}

看上去逻辑还是挺简单的,往WallpaperColors传入一个Bitmap就可以得到一系列的颜色集合,en~ ,我们来看看究竟是怎么实现的。

[WallpaperColors.java]

private static final int MAX_BITMAP_SIZE = 112;

private static final int MAX_WALLPAPER_EXTRACTION_AREA = MAX_BITMAP_SIZE * MAX_BITMAP_SIZE;

public static WallpaperColors fromBitmap(@NonNull Bitmap bitmap) {
    if (bitmap == null) {
        throw new IllegalArgumentException("Bitmap can't be null");
    }

    //步骤1,计算出图像的面积,超过112*112的,会进行压缩处理,
    final int bitmapArea = bitmap.getWidth() * bitmap.getHeight();
    boolean shouldRecycle = false;
    if (bitmapArea > MAX_WALLPAPER_EXTRACTION_AREA) {
        shouldRecycle = true;
        Size optimalSize = calculateOptimalSize(bitmap.getWidth(), bitmap.getHeight());
        bitmap = Bitmap.createScaledBitmap(bitmap, optimalSize.getWidth(),
                optimalSize.getHeight(), false /* filter */);
    }

    //步骤2,根据机器的配置,采用不同的策略
    final Palette palette;
    if (ActivityManager.isLowRamDeviceStatic()) {
        palette = Palette
                .from(bitmap, new VariationalKMeansQuantizer())
                .maximumColorCount(5)
                .resizeBitmapArea(MAX_WALLPAPER_EXTRACTION_AREA)
                .generate();
    } else {
        palette = Palette
                .from(bitmap, new CelebiQuantizer())
                .maximumColorCount(128)
                .resizeBitmapArea(MAX_WALLPAPER_EXTRACTION_AREA)
                .generate();
    }
    
    //步骤3,对提取出的颜色进行排序
    final ArrayList<Palette.Swatch> swatches = new ArrayList<>(palette.getSwatches());
    swatches.sort((a, b) -> b.getPopulation() - a.getPopulation());

    final int swatchesSize = swatches.size();

    final Map<Integer, Integer> populationByColor = new HashMap<>();
    for (int i = 0; i < swatchesSize; i++) {
        Palette.Swatch swatch = swatches.get(i);
        int colorInt = swatch.getInt();
        populationByColor.put(colorInt, swatch.getPopulation());

    }

    //步骤4,检测图像是否支持light text
    int hints = calculateDarkHints(bitmap);

    //步骤5,超过大小限制的图像,进行释放
    if (shouldRecycle) {
        bitmap.recycle();
    }

    //步骤6,构造一个WallpaperColors,并进行返回
    return new WallpaperColors(populationByColor, HINT_FROM_BITMAP | hints);
}

这个方法内容还是比较多的,我们总结一下:

  • 区域取色传入的bitmap,面积不能超过112*112,超过会进行压缩,最后进行释放
  • 根据机器的配置,所采集的颜色和策略不同
    • 低配置机器最多采集5种颜色
    • 高配置机器最多采集128种
    • Android 11及以下,无论什么配置最多采集5种
  • 颜色排序是依照此样本所占的像素数

至于上面返回的WallpaperColors,有什么作用呢,我们来看一下它以下接口:

//获取墙纸最具视觉代表性的颜色。“视觉上具有代表性”是指在图像中很容易被注意到,可能发生在高频率。不为空
public @NonNull Color getPrimaryColor() {
    return mMainColors.get(0);
}

//获取壁纸的第二个最杰出的颜色。可以为空
public @Nullable Color getSecondaryColor() {
    return mMainColors.size() < 2 ? null : mMainColors.get(1);
}

//获得壁纸的第三个最杰出的颜色。可以为空
public @Nullable Color getTertiaryColor() {
    return mMainColors.size() < 3 ? null : mMainColors.get(2);
}
    
//最杰出的颜色列表,按重要性排序
public @NonNull List<Color> getMainColors() {
    return Collections.unmodifiableList(mMainColors);
}
    
//得到所有提取出来的颜色,key是颜色的rgb int值 ,value是出现次数 
 public @NonNull Map<Integer, Integer> getAllColors() {
    return Collections.unmodifiableMap(mAllColors);
}

这就很明了啦,我们要的所有颜色,以及前三种最具代表性的颜色,都通过接口提供出来了。

我们回到2.2.3的步骤2,返回获取到的颜色集。

public void notifyLocalColorsChanged(@NonNull List<RectF> regions,
                @NonNull List<WallpaperColors> colors)
                throws RuntimeException {
    for (int i = 0; i < regions.size() && i < colors.size(); i++) {
        WallpaperColors color = colors.get(i);
        RectF area = regions.get(i);
        if (color == null || area == null) {
            continue;
        }
        try {
            //关键点,通过IWallpaperConnection通知注册端 ,已经拿到颜色 
            mConnection.onLocalWallpaperColorsChanged(
                    area,
                    color,
                    mDisplayContext.getDisplayId()
            );
        } catch (RemoteException e) {
            throw new RuntimeException(e);
        }
    }
    ...
}

IWallpaperConnection 是WallpaperService和WallpaperManagerService绑定壁纸服务时创建的,以于两者通信。所以这里流程就从WallpaperService转回到WallpaperManagerService中了。

[WallpaperManagerService.java]

public void onLocalWallpaperColorsChanged(RectF area, WallpaperColors colors,
                int displayId) {
    forEachDisplayConnector(displayConnector -> {
        if (displayConnector.mDisplayId == displayId) {
            RemoteCallbackList<ILocalWallpaperColorConsumer> callbacks;
            ArrayMap<IBinder, Integer> callbackDisplayIds;
            synchronized (mLock) {
                callbacks = mLocalColorAreaCallbacks.get(area);
                callbackDisplayIds = new ArrayMap<>(mLocalColorCallbackDisplayId);
            }
            if (callbacks == null) return;
            callbacks.broadcast(c -> {
                try {
                    Integer targetDisplayId =
                            callbackDisplayIds.get(c.asBinder());
                    if (targetDisplayId == null) return;
                    //关键点
                    if (targetDisplayId == displayId) c.onColorsChanged(area, colors);
                } catch (RemoteException e) {
                    e.printStackTrace();
                }
            });
        }
    });
}

可能大家会发现,这里几乎每个方法都传入了displayid,并在逻辑在进行判断。主要是因为壁纸跟 Display是有绑定关系的。

这里通过ILocalWallpaperColorConsumer调用onColorsChanged方法,其实流程就又回到了接口注册方,也就是我们的PixelLauncher中。

2.3、Launcher进行颜色处理

因为PixelLauncher被混淆得厉害,我们只能反编译出部分内容,但层层剥茧下来,我们还是能了解个大致流程的。

[PixelLauncher-->LocalWallpaperColorsExtractor.java]

public void onColorsChanged(RectF rectF, WallpaperColors wallpaperColors) {
    LocalColorExtractor.Listener listener;
    if (a() && (listener = this.colorExtractorListener) != null) {
        //关键点
        listener.onColorsChanged(rectF, generateColorsOverride(wallpaperColors));
    }
    
}  

public SparseIntArray generateColorsOverride(WallpaperColors wallpaperColors) {
    ...
    return aVar.b(wallpaperColors);
}

这里会通过调用generateColorsOverride将WallpaperColors类型转移成SparyIntArray类型,并进行颜色二次加工,使其符合Material Design 设计风格。

颜色二次加工,其实就是Google说的Android 12 主题引擎“monet"的部分功能。不过就目前Beta 4版本,Monet是还没有开源的,我们看不到相关实现。如果后续有机会,再给大家分享这部分。

颜色处理完之后,就会回调到LauncherAppWidgetHostView中

[LauncherAppWidgetHostView.java]
public void onColorsChanged(RectF rectF, SparseIntArray colors) {
        ...

        // setColorResources will reapply the view, which must happen in the UI thread.
        post(() -> setColorResources(colors));
}
    

@Override
public void setColorResources(@Nullable SparseIntArray colors) {
    if (colors == null) {
        //关键点1,提取不到颜色,会重置插件的颜色
        resetColorResources();
    } else {
        //关键点2,设置插件的颜色
        super.setColorResources(colors);
    }
}  

2.4 、Widget动态变色

从上文可以看出,这里分为两种情况,颜色提取成功或失败

2.4.1、颜色提取失败

此时如果之前修改过颜色,则重置动态重载的资源,恢复所有颜色的默认值,并reaplly RemoteView。

[AppWidgetHostView.java]
public void resetColorResources() {
    if (mColorResources != null) {
        mColorResources = null;
        mColorMapping = null;
        mLayoutId = -1;
        mViewMode = VIEW_MODE_NOINIT;
        reapplyLastRemoteViews();
    }
}

2.4.2 、颜色提取成功

如果成功获取到颜色集,则会进入到AppWidgetHostView的setColorResource中,设置动态重载的颜色资源

public void setColorResources(@NonNull SparseIntArray colorMapping) {
    //步骤1,判断颜色资源集跟上次设置的是否相同,若是,则不作处理
    if (mColorMapping != null && isSameColorMapping(mColorMapping, colorMapping)) {
        return;
    }
    mColorMapping = colorMapping.clone();
    //步骤2,通过RemoteViews创建一个ColorResources
    mColorResources = RemoteViews.ColorResources.create(mContext, mColorMapping);
    mLayoutId = -1;
    mViewMode = VIEW_MODE_NOINIT;
    //步骤3,reapply remoteview
    reapplyLastRemoteViews();
}

在分析上面代码之前,先要说一下,经过转换之后,传入的colorMapping是一个什么样的存在。

colorMapping :将一组预定义的颜色资源映射到它们的 ARGB 表示。 任何不在预定义颜色集中的条目都将被忽略。

可以重载的颜色资源是名称以{@code system_neutral}或{@code system_accent}为前缀的资源,例如{@link android.R.color#system_neutral1_500}。

所以并不是什么颜色资源都可以重载,比如你定义了一个@color/myblue ,却发现没有变色,其实是因为你没有用Android 预定义的颜色集资源。

回到上面的代码,我们的主要关注点是步骤2和3,通过RemoteView去创建一个新的ColorResources。

关于这个ColorResources,如果之前对Widget有研究的话就会知道,这个东西之前并不存在,是Android 12为了动态变色新增的类。

public static ColorResources create(Context context, SparseIntArray colorMapping) {
    try {
        //步骤1 ,从存储在 APK 中的资产创建编译的资源内容。
        byte[] contentBytes = createCompiledResourcesContent(context, colorMapping);
        if (contentBytes == null) {
                    return null;
        }
        FileDescriptor arscFile = null;
        try {
            //步骤2
            arscFile = Os.memfd_create("remote_views_theme_colors.arsc", 0 /* flags */);
               // Note: This must not be closed through the OutputStream.
            try (OutputStream pipeWriter = new FileOutputStream(arscFile)) {
                pipeWriter.write(contentBytes);

                try (ParcelFileDescriptor pfd = ParcelFileDescriptor.dup(arscFile)) {
                    //步骤3
                    ResourcesLoader colorsLoader = new ResourcesLoader();
                    //步骤4
                    colorsLoader.addProvider(ResourcesProvider
                            .loadFromTable(pfd, null /* assetsProvider */));
                    //步骤5
                    return new ColorResources(colorsLoader);
                }
            }
        } finally {
            if (arscFile != null) {
                Os.close(arscFile);
            }
        }
    } catch (Exception ex) {
        Log.e(LOG_TAG, "Failed to setup the context for theme colors", ex);
    }
    return null;
    }
}

上面这代代码,算是Widget动态变色的精华所在了,让我们一步步分析。

步骤1,从存储在 APK 中的资产创建编译的资源内容。
private static byte[] createCompiledResourcesContent(Context context,
                SparseIntArray colorResources) throws IOException {
    byte[] content;
    try (InputStream input = context.getResources().openRawResource(
            com.android.internal.R.raw.remote_views_color_resources)) {
        ByteArrayOutputStream rawContent = readFileContent(input);
        content = rawContent.toByteArray();
    }
    
    //下面都是计算各种资源在所有位置中的位置。
    int valuesOffset =
            content.length - (LAST_RESOURCE_COLOR_ID & 0xffff) * ARSC_ENTRY_SIZE - 4;
    if (valuesOffset < 0) {
        Log.e(LOG_TAG, "ARSC file for theme colors is invalid.");
        return null;
    }
    for (int colorRes = FIRST_RESOURCE_COLOR_ID; colorRes <= LAST_RESOURCE_COLOR_ID;
                    colorRes++) {
        // The last 2 bytes are the index in the color array.
        int index = colorRes & 0xffff;
        int offset = valuesOffset + index * ARSC_ENTRY_SIZE;
        int value = colorResources.get(colorRes, context.getColor(colorRes));
        // Write the 32 bit integer in little endian
        for (int b = 0; b < 4; b++) {
            content[offset + b] = (byte) (value & 0xff);
            value >>= 8;
        }
    }
    return content;
}

这个R.raw.remote_views_color_resources资源也是Android 12新增的,里面存放了一些颜色资源。

步骤2,通过Os.memfd_create创建arsc文件

memfd_create调用的是Linux接口,会创建一个匿名文件并返回一个指向这个文件的文件描述符.这个文件就像是一个普通文件一样,所以能够被修改,截断,内存映射等等.不同于一般文件,此文件是保存在RAM中.一旦所有指向这个文件的连接丢失,那么这个文件就会自动被释放

这里通过memfd_create去创建了一个remote_views_theme_colors.arsc文件,获得其文件句柄,将要overlay的颜色资源写入其中,也是一个很巧妙的作法,这样但连接一丢失,文件就自动释放销毁了,无需手动删除。

步骤3和4,创建ResourcesLoader、ResourcesProvider
try (ParcelFileDescriptor pfd = ParcelFileDescriptor.dup(arscFile)) {
    ResourcesLoader colorsLoader = new ResourcesLoader();
    colorsLoader.addProvider(ResourcesProvider
                    .loadFromTable(pfd, null /* assetsProvider */));
   ...
}

这两步放着一起讲,是因为ResourcesLoader、ResourcesProvider是Android 11 引入了一个新 API,允许应用动态扩展资源的搜索和加载方式。两者协同作用,可以提供额外的资源,或修改现有资源的值。ResourcesLoader 对象是向应用的 Resources 实例提供 ResourcesProvider 对象的容器

在这一步,我们获得前面创建的remote_views_theme_colors.arsc的ParcelFileDescriptor,通过
ResourcesProvider.loadFromTable方法创建了一个ResourcesProvider,其主要用于自定义解析指定目录的基于文件的资源,或者从其他APK中解析资源。接着我们可以通过ResourcesLoader来装这个Provider,而ResourcesLoader对象则负责向Resources实例提供一个容器。

于是我们得到了一个动态扩展资源的基本步骤:

  1. 创建ResourcesProvider,其加载资源有两种方法,从APK(.apk)或是资源表(resources tables,resources.arsc)
  2. 创建ResourcesLoader对象,并且添加上面我们已经创建好的ResourcesProvider
  3. 创建Resources对象,添加我们写好的Loaders
  4. 利用AssetManager类中的方法来访问这些数据
步骤5,创建一个ColorResources
try (ParcelFileDescriptor pfd = ParcelFileDescriptor.dup(arscFile)) {
    ...
    return new ColorResources(colorsLoader);
}

这里只是new ColorResource,将前面创建的clorsLoader 传入进去,我们跟进去看看是怎么回事

[RemoteView.ColorResources.java]

private ColorResources(ResourcesLoader loader) {
    mLoader = loader;
}

//将颜色资源应用于给定的上下文。
public void apply(Context context) {
    context.getResources().addLoaders(mLoader);
}

这里apply方法在RemoteView的inflateView方法会调用到

[RemoteView.java]

private View inflateView(Context context, RemoteViews rv, @Nullable ViewGroup parent,
            @StyleRes int applyThemeResId, @Nullable ColorResources colorResources) {
    ...
    final Context contextForResources = getContextForResources(context);
    if (colorResources != null) {
        colorResources.apply(contextForResources);
    }
   ...
}

好了,这一步走完,就已经创建Resources对象,添加我们写好的Loaders。接下来就是我们最后一步,更新我们的RemoteView。

更新RemoteView

需要说明一点,无论前面提取颜色成功或失败,最后都会走到这一步,也就是reapplyLastRemoteViews。我们这里再回顾一下:

提取成功->设置Widget颜色

public void setColorResources(@NonNull SparseIntArray colorMapping) {
    ...
    mColorResources = RemoteViews.ColorResources.create(mContext, mColorMapping);
    ...
    //关键点
    reapplyLastRemoteViews();
}

提取失败->重置Widget颜色

 public void resetColorResources() {
    if (mColorResources != null) {
        mColorResources = null;
        mColorMapping = null;
        mLayoutId = -1;
        mViewMode = VIEW_MODE_NOINIT;
        //关键点
        reapplyLastRemoteViews();
    }
}

现在就让我们看下最后一步是怎么做的吧。

private void reapplyLastRemoteViews() {
    SparseArray<Parcelable> savedState = new SparseArray<>();
    saveHierarchyState(savedState);
    //关键点
    applyRemoteViews(mLastInflatedRemoteViews, true);
    restoreHierarchyState(savedState);
}
    
    
protected void applyRemoteViews(@Nullable RemoteViews remoteViews, boolean useAsyncIfPossible) {
    ...
    if (remoteViews == null) {
       ...
    } else {
        ...
        if (mAsyncExecutor != null && useAsyncIfPossible) {
            //关键点
            inflateAsync(rvToApply);
            return;
        }
        int layoutId = rvToApply.getLayoutId();
        if (rvToApply.canRecycleView(mView)) {
            try {
                //关键点
                rvToApply.reapply(mContext, mView, mInteractionHandler, mCurrentSize,
                            mColorResources);
                    content = mView;
                mLastInflatedRemoteViewsId = rvToApply.computeUniqueId(remoteViews);
                recycled = true;
            } catch (RuntimeException e) {
                exception = e;
            }
        }

        // Try normal RemoteView inflation
        if (content == null) {
            try {
                //关键点
                content = rvToApply.apply(mContext, this, mInteractionHandler,
                        mCurrentSize, mColorResources);
                mLastInflatedRemoteViewsId = rvToApply.computeUniqueId(remoteViews);
            } catch (RuntimeException e) {
                exception = e;
            }
        }

        ...
    }

    applyContent(content, recycled, exception);
}
    
private void inflateAsync(@NonNull RemoteViews remoteViews) {
    mRemoteContext = getRemoteContext();
    int layoutId = remoteViews.getLayoutId();
    ...
        if (layoutId == mLayoutId && mView != null) {
            try {
                //关键点
                mLastExecutionSignal = remoteViews.reapplyAsync(mContext,
                        mView,
                        mAsyncExecutor,
                        new ViewApplyListener(remoteViews, layoutId, true),
                        mInteractionHandler,
                        mCurrentSize,
                        mColorResources);
            } catch (Exception e) {
                // Reapply failed. Try apply
            }
        }
        if (mLastExecutionSignal == null) {
            ////关键点
            mLastExecutionSignal = remoteViews.applyAsync(mContext,
                    this,
                    mAsyncExecutor,
                    new ViewApplyListener(remoteViews, layoutId, false),
                    mInteractionHandler,
                    mCurrentSize,
                    mColorResources);
        }
    }

从上述代码中可以看到,remoteview的各个apply、applyAsync、reapplyAsync方法都传入了前面创建的colorResource,使其颜色动态overlay,逻辑还是清晰的。

分析到这里,Widget自动变色的原理基本就分析完了,希望大家能有收获。

三、结语

研究下来,感觉Android 12 这些特性也不是突然新增的,而是前面的版本一点点累积下来,从而构建出的新功能。比如:

  • Android 5.0 引入的Material Design
  • Android 8.0 增加的OverlayManagerService 进行overlay package
  • Android 9.0 新增的WallpaperManager 整体取色接口(没有默认实现)
  • Android 11.0 新增的ResourceLoader + ResourcesProvider 动态扩展资源的搜索和加载方式
  • Android 12.0 新增的WallpaperManager 局部取色接口(包含默认实现)

总之,量变形成质变, 让我们期待更好看的Android。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342

推荐阅读更多精彩内容