Loading...
墨滴

掀乱书页的风

2021/10/16  阅读:23  主题:兰青

android换肤原理

先来张YY图 image.png

方案及轮子

皮肤可能多种多样,但是皮肤的替换万变不离其宗,主要分为两种方式

  1. 主题换肤 (暗黑模式) 对于主题换肤google在support库在23.2版本新增夜间模式。只不过夜间模式的开关需要应用自己实现,而暗黑模式是整个操作系统已实现全局的切换功能

  2. 动态加载资源包 下面会主要讲解动态加载资源包的方式,我们选择的轮子是android-skin-support

思路

插件化的方式获取资源,通过代理模式的方式拦截view的显示,参考TextView在创建的时候会被android系统替换成AppcompatTextview

image.png 动态加载资源包换肤的流程,我们来分析下里面的关键步骤

加载资源包

Res资源加载流程

应用资源加载的过程 主要涉及两个类: Resource只与应用程序交互,负责加载资源的管理等等;AssetManager负责res目录中所有的资源文件,打开文件,并读取到内存中。

当使用Context.getDrawable()方法 通过资源ID 生成一个Drawable对象时,最终会调用到Resource的getDrawable(...)方法。

public Drawable getDrawable(@DrawableRes int id, @Nullable Theme theme)
        throws NotFoundException {
    return getDrawableForDensity(id, 0, theme);
}

内部函数调用如下

image.png
image.png

ResourcesImpl 是Resource内部的一个静态代理类,实际负责与AssetManager的交互。

在loadDrawableForCookie() 方法中真正开始加载资源,假如该id 对应的是一个xml文件,则开始xml解析,假如该id对应的一个图片文件,则调用AssetManager打开文件。

AssetManager实际上调用Native方法打开文件。

public @NonNull InputStream openNonAsset(...) {
    synchronized (this) {
        final long asset = nativeOpenNonAsset(mObject, cookie, fileName, accessMode);
        final AssetInputStream assetInputStream = new AssetInputStream(asset);
        return assetInputStream;
    }
}

要使用AssetManager可以打开res目录中资源文件,必须把res路径添加到AssetManager的path中。 AssetManager.addAssetPath(...)方法为隐藏方法,需要反射调用

AssetManager assetManager = AssetManager.class.newInstance();
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
addAssetPath.invoke(assetManager, skinPkgPath);

获取当前的app的Resources,主要是为了创建apk的Resources

Resources superRes = context.getResources();
Resources skinResource = new Resources(assetManager, superRes.getDisplayMetrics(), superRes.getConfiguration());

网上大多数都是这种解决方案,运行起来也没有问题,可以获取到皮肤包里面的内容 但是58app将targetsdk升级到了28,通过反射调用api会有黑灰名单的限制,为了解决这个问题,使用getResourcesForApplication这个api来避免使用反射

  • 通过皮肤apk的路径,获取皮肤包信息
PackageInfo packageInfo = mAppContext.getPackageManager().getPackageArchiveInfo(skinPkgPath, 0);
            packageInfo.applicationInfo.sourceDir = skinPkgPath;
            packageInfo.applicationInfo.publicSourceDir = skinPkgPath;
  • 获取皮肤Resources,res.getAssets就已经包括了皮肤包的内容
Resources res =mAppContext.getPackageManager().getResourcesForApplication(packageInfo.applicationInfo);
Resources superRes = mAppContext.getResources();
return new Resources(res.getAssets(), superRes.getDisplayMetrics(), superRes.getConfiguration());

这个地方最后new Resources是没有问题的,但是我在测试的过程中发现通过getResourcesForApplication获取的res就已经可以获取皮肤包的内容,这个可以作为这个框架的一个优化点吧

资源id动态映射

资源文件在编译打包后会生成一张资源表resourse.arsc, 将具体的资源文件与资源表中 ID一一对应。运行时,在由AssetManager根据资源表加载相应文件。但皮肤包中相同资源打包编译后,相同资源文件在资源表中对应的ID却不一样 image.png 为解决这个问题,可以通过动态映射找出皮肤包中对应的资源Id,原理是因为相同资源在不同的资源表中的Type和Name一样。

int getTargetResId(Context context, int resId) {
        try {
            String resName = null;
            if (mStrategy != null) {
                resName = mStrategy.getTargetResourceEntryName(context, mSkinName, resId);
            }
            if (TextUtils.isEmpty(resName)) {
                resName = context.getResources().getResourceEntryName(resId);
            }
            String type = context.getResources().getResourceTypeName(resId);
            return mResources.getIdentifier(resName, type, mSkinPkgName);
        } catch (Exception e) {
            // 换肤失败不至于应用崩溃.
            return 0;
        }
    }

系统View创建过程

image.png
image.png
  1. 最终是调用LayoutInflater中的mFactory2.onCreateView来创建view,那么mFactory2是何时赋值的呢 2.AppCompatActivity 将大部分生命周期委托给了AppCompatDelegate,AppCompatDelegateImpl中,在LayoutInflaterFactory的接口方法onCreateView 中将View的创建交给了AppCompatViewInflater,看下它的实现
final View createView(View parent, final String name, @NonNull Context context,
            @NonNull AttributeSet attrs, boolean inheritContext,
            boolean readAndroidTheme, boolean readAppTheme, boolean wrapContext)
 
{
        final Context originalContext = context;

        ......

        View view = null;

        // We need to 'inject' our tint aware Views in place of the standard framework versions
        switch (name) {
            case "TextView":
                view = createTextView(context, attrs);
                verifyNotNull(view, name);
                break;
            case "ImageView":
                view = createImageView(context, attrs);
                verifyNotNull(view, name);
                break;
            case "Button":
                view = createButton(context, attrs);
                verifyNotNull(view, name);
                break;
            case "EditText":
                view = createEditText(context, attrs);
                verifyNotNull(view, name);
                break;
            case "Spinner":
                view = createSpinner(context, attrs);
                verifyNotNull(view, name);
                break;
            case "ImageButton":
                view = createImageButton(context, attrs);
                verifyNotNull(view, name);
                break;
            case "CheckBox":
                view = createCheckBox(context, attrs);
                verifyNotNull(view, name);
                break;
            case "RadioButton":
                view = createRadioButton(context, attrs);
                verifyNotNull(view, name);
                break;
            case "CheckedTextView":
                view = createCheckedTextView(context, attrs);
                verifyNotNull(view, name);
                break;
            case "AutoCompleteTextView":
                view = createAutoCompleteTextView(context, attrs);
                verifyNotNull(view, name);
                break;
            case "MultiAutoCompleteTextView":
                view = createMultiAutoCompleteTextView(context, attrs);
                verifyNotNull(view, name);
                break;
            case "RatingBar":
                view = createRatingBar(context, attrs);
                verifyNotNull(view, name);
                break;
            case "SeekBar":
                view = createSeekBar(context, attrs);
                verifyNotNull(view, name);
                break;
            default:
                // The fallback that allows extending class to take over view inflation
                // for other tags. Note that we don't check that the result is not-null.
                // That allows the custom inflater path to fall back on the default one
                // later in this method.
                view = createView(context, name, attrs);
        }

        if (view == null && originalContext != context) {
            // If the original context does not equal our themed context, then we need to manually
            // inflate it using the name so that android:theme takes effect.
            view = createViewFromTag(context, name, attrs);
        }
        return view;
    }

可以看到如果在xml中写了一个TextView控件,其实是通过我们写的控件名称判断是什么控件,然后去new的方式创建出来的,并且new的不是TextView,而是AppCompatTextView.其他的一些系统控件也是这么new出来的.AppCompatViewInflater就是专门用来创建View的,使用了面向对象的五大原则之一--单一职责原则

换肤View创建过程

image.png
image.png
  1. 重写了AppcompatActivity中的getDelete方法,将installViewFactory重写为空实现,如果只进行这一步,那我们的View就没法创建了
  2. 所以第二步为所有AppcompatActivity添加了lifecycle的回调,在onActivityCreated中执行installLayoutFactory替换为SkinCompatDelegate,这里同样是使用了代理模式修改为支持换肤的SkinCompatViewInflater,注意SkinCompatViewInflater是一个泛指,包括一系列支持换肤的库,appcompat、design、cardview等等,我们看下其中一段
 private View createViewFromFV(Context context, String name, AttributeSet attrs) {
        View view = null;
        if (name.contains(".")) {
            return null;
        }
        switch (name) {
            case "View":
                view = new SkinCompatView(context, attrs);
                break;
            case "LinearLayout":
                view = new SkinCompatLinearLayout(context, attrs);
                break;
            case "RelativeLayout":
                view = new SkinCompatRelativeLayout(context, attrs);
                break;
            case "FrameLayout":
                view = new SkinCompatFrameLayout(context, attrs);
                break;
            case "TextView":
                view = new SkinCompatTextView(context, attrs);
                break;
            case "ImageView":
                view = new SkinCompatImageView(context, attrs);
                break;
            case "Button":
                view = new SkinCompatButton(context, attrs);
                break;
            case "EditText":
                view = new SkinCompatEditText(context, attrs);
                break;
            case "Spinner":
                view = new SkinCompatSpinner(context, attrs);
                break;
            case "ImageButton":
                view = new SkinCompatImageButton(context, attrs);
                break;
            case "CheckBox":
                view = new SkinCompatCheckBox(context, attrs);
                break;
            case "RadioButton":
                view = new SkinCompatRadioButton(context, attrs);
                break;
            case "RadioGroup":
                view = new SkinCompatRadioGroup(context, attrs);
                break;
            case "CheckedTextView":
                view = new SkinCompatCheckedTextView(context, attrs);
                break;
            case "AutoCompleteTextView":
                view = new SkinCompatAutoCompleteTextView(context, attrs);
                break;
            case "MultiAutoCompleteTextView":
                view = new SkinCompatMultiAutoCompleteTextView(context, attrs);
                break;
            case "RatingBar":
                view = new SkinCompatRatingBar(context, attrs);
                break;
            case "SeekBar":
                view = new SkinCompatSeekBar(context, attrs);
                break;
            case "ProgressBar":
                view = new SkinCompatProgressBar(context, attrs);
                break;
            case "ScrollView":
                view = new SkinCompatScrollView(context, attrs);
                break;
            default:
                break;
        }
        return view;
    }

原理完全就是照搬系统view的创建过程呀,只不过生成的是支持换肤的view了

换肤时如何刷新View

刷新流程

image.png
image.png
  1. 异步获取资源信息
  2. 通知Activity更新
  3. 通知View更新 那么如何通知Activity更新呢,在SkinActivityLifecycle的onActivityResumed()方法中,为每一个Activity都添加了独立的SkinObservable
@Override
public void onActivityResumed(Activity activity) {
    mCurActivityRef = new WeakReference<>(activity);
    if (isContextSkinEnable(activity)) {
        LazySkinObserver observer = getObserver(activity);
        //这里
        SkinCompatManager.getInstance().addObserver(observer);
        observer.updateSkinIfNeeded();     
    }
 }

从这里也可以看出,换肤并不是所有页面马上刷新,而是当前页面处于onResumed状态才执行换肤

Activity中的View刷新

是全部更新还是重新绘制呢,直接从SkinCompatDelegate寻找答案

@Override
    public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
        View view = createView(parent, name, context, attrs);

        if (view == null) {
            return null;
        }
 //看这里
        if (view instanceof SkinCompatSupportable) {
            mSkinHelpers.add(new WeakReference<>((SkinCompatSupportable) view));
        }

        return view;
    }

在View创建的时候,将支持换肤的view存储到mSkinHelpers中,是一个list,在刷新皮肤的时候遍历查找支持换肤的View进行刷新,也就是说并不是当前页面所有View全部刷新,而是仅刷新支持换肤的View

public void applySkin() {
        if (mSkinHelpers != null && !mSkinHelpers.isEmpty()) {
            for (WeakReference ref : mSkinHelpers) {
                if (ref != null && ref.get() != null) {
                    ((SkinCompatSupportable) ref.get()).applySkin();
                }
            }
        }
    }

优势

这样的做法与网上其他框架相比优势在哪里, 为什么重复造轮子

1.在增加框架开发成本的基础上降低了框架使用的成本, 一次开发, 所有Android 开发者都受用; 2.换肤框架对业务代码的侵入性比较小, 不需要实现接口重写方法, 不需要其他额外的代码, 接入方便 3.深入android源码, 和android源码实现方式类似, 兼容性更好.

掀乱书页的风

2021/10/16  阅读:23  主题:兰青

作者介绍

掀乱书页的风