Android切换皮肤原理的一些理解

前言

参照了Android-skin-support 这个开源库,通过阅读了这个开源库,进行了学习,总结出来的笔记

基本的使用方式,其实框架的github里面讲的挺清楚了。

1、引入库

我这边还是support包,暂时没用androidx,所以导入的是support

implementation 'skin.support:skin-support:3.1.4'                   // skin-support 基础控件支持
implementation 'skin.support:skin-support-design:3.1.4'            // skin-support-design material design 控件支持[可选]
implementation 'skin.support:skin-support-cardview:3.1.4'          // skin-support-cardview CardView 控件支持[可选]
implementation 'skin.support:skin-support-constraint-layout:3.1.4' // skin-support-constraint-layout ConstraintLayout 控件支持[可选]

在Application的onCreate中初始化

@Override
public void onCreate() {
    super.onCreate();
    SkinCompatManager.withoutActivity(this)                         // 基础控件换肤初始化
            .addInflater(new SkinMaterialViewInflater())            // material design 控件换肤初始化[可选]
            .addInflater(new SkinConstraintViewInflater())          // ConstraintLayout 控件换肤初始化[可选]
            .addInflater(new SkinCardViewInflater())                // CardView v7 控件换肤初始化[可选]
            .setSkinStatusBarColorEnable(false)                     // 关闭状态栏换肤,默认打开[可选]
            .setSkinWindowBackgroundEnable(false)                   // 关闭windowBackground换肤,默认打开[可选]
            .loadSkin();
}

在BaseActivity里面使用这个SkinAppCompatDelegateImpl这个方法来代理源码中的创建的AppCompatDelegate。

@NonNull
@Override
public AppCompatDelegate getDelegate() {
    return SkinAppCompatDelegateImpl.get(this, this);
}

补:这里补充一下自己的理解。这个代理,直接其实就是让AppCompatDelegate # installViewFactory 方法,延迟导入LayoutInflater,通过实现Application.ActivityLifecycleCallbacks的 activity 生命周期方法来注入LayoutInflater。

//android.view.LayoutInflater.java
public void setFactory(Factory factory) {
    if (mFactorySet) {
        throw new IllegalStateException("A factory has already been set on this LayoutInflater");
    }
    if (factory == null) {
        throw new NullPointerException("Given factory can not be null");
    }
    mFactorySet = true;
    if (mFactory == null) {
        mFactory = factory;
    } else {
        mFactory = new FactoryMerger(factory, null, mFactory, mFactory2);
    }
}

如果在一开始设置上去了,就会抛出"A factory has already been set on this LayoutInflater"异常,或者通过反射的方式先重置了mFactorySet为false,然后重新设置上自己的LayoutInflater。


//assets方式切换皮肤
SkinCompatManager.getInstance().loadSkin("night.skin", null, SkinCompatManager.SKIN_LOADER_STRATEGY_ASSETS);
//...zip等方式加载皮肤都可以自己设置

//重置为原皮肤
SkinCompatManager.getInstance().restoreDefaultTheme();

这样就可以把换肤框架使用起来了。

一、为什么要换肤,什么叫换肤

个人理解:让用户体验会更好

换肤:就是认为动态的替换资源(文字、颜色、字体大小、图片,布局文件…),例如使用View的setBackgroundResource,setTextSize等函数

上面可以提取2个问题

  1. 换肤资源怎么获取
  2. 换肤资源怎么设置

二、换肤资源怎么设置

基于api 26

主角就是LayoutInfater,xml布局获取的加载。

为什么是 LayoutInfater呢。换肤就是能拿到换肤控件的对象,然后进行调用 setTextSize、setBackgroundResource,setImageResource,setTextColor等。关键是这么拿到这些换肤的控件对象。

LayoutInflater 获取方式

LayoutInfater是个抽象类,最终发现是 PhoneLayoutInfater LayoutInfater -》 PhoneLayoutInfater

怎么知道的呢?那就我们怎么获取LayoutInflater这么获取的,获取方式:

  1. Activity # getLayoutInflater() -> 最终是在PhoneWindow的成员变量,这个成员变量是在PhoneWindow构造方法调用的,最终其实还是context.getSystemService的方式来获取的
@UnsupportedAppUsage
public PhoneWindow(Context context) {
    super(context);
    mLayoutInflater = LayoutInflater.from(context);
}
  1. fragment # getLayoutInflater() -> 点击源码看的时候,最终还是通过
LayoutInflater LayoutInflater =
        (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
  1. LayoutInflater.from(context)

补充:我们常用的View.inflate加载布局,其实内部也是通过LayoutInflater.from()

总结:最终还是通过context.getSystemService(Context.LAYOUT_INFLATER_SERVICE)来进行获取的LayoutInflater对象

看了一下getSystemService这个方法在Context里面,那就直接看ContextImpl.java的getSystemService方法

@Override
public Object getSystemService(String name) {
    return SystemServiceRegistry.getSystemService(this, name);
}

SystemServiceRegistry类的注释上说明

Manages all of the system services that can be returned by {@link Context#getSystemService}. * Used by {@link ContextImpl}.

管理的所有的服务提供给用户使用

注:仿照这个类应该可以自己弄一个app的aidl管理服务类

/**
 * Gets a system service from a given context.
 */
public static Object getSystemService(ContextImpl ctx, String name) {
    ServiceFetcher<?> fetcher = SYSTEM_SERVICE_FETCHERS.get(name);
    return fetcher != null ? fetcher.getService(ctx) : null;
}

上面方法可以知道,服务是根据上下文来进行获取的

在注册服务的时候,这里实现了

registerService(Context.LAYOUT_INFLATER_SERVICE, LayoutInflater.class,
        new CachedServiceFetcher<LayoutInflater>() {
    @Override
    public LayoutInflater createService(ContextImpl ctx) {
        return new PhoneLayoutInflater(ctx.getOuterContext());
    }});

差不多了,我们已经知道了2点

1)我们的LayoutInflater是PhoneLayoutInflater

2)不同的Context都会创建一个PhoneLayoutInflater

LayoutInfater#inflate 使用,以及实现创建View的过程源代码查看

回过头来,我们获取LayoutInflater之后通常会进行

inflate(int resource, ViewGroup root, boolean attachToRoot)

然后进行如下步骤:

inflater() -> createViewFromTag -> createViewFromTag(5 params) -> tryCreateView -> Factory.onCreateView

inflater()中

XmlResourceParser parser = res.getLayout(resource);

//获取属性
final AttributeSet attrs = Xml.asAttributeSet(parser);

通过PULL解析获取属性(layout_height="-1", layout_width="-1"等)

View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
                           boolean ignoreThemeAttr) {
        if (name.equals("view")) {
            name = attrs.getAttributeValue(null, "class");
        }
        //...略
        View view;
        if (mFactory2 != null) {
            view = mFactory2.onCreateView(parent, name, context, attrs);
        } else if (mFactory != null) {
            view = mFactory.onCreateView(name, context, attrs);
        } else {
            view = null;
        }
            //...略

        return view;
    }

这里最终调用了 mFactory2 这个对象,这个对象的默认实现,我们回过头来看AppCompatActivity里面的

//android.support.v7.app.AppCompatActivity
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
    final AppCompatDelegate delegate = getDelegate();
    
    delegate.installViewFactory();
    
    delegate.onCreate(savedInstanceState);
    if (delegate.applyDayNight() && mThemeId != 0) {
        if (Build.VERSION.SDK_INT >= 23) {
            onApplyThemeResource(getTheme(), mThemeId, false);
        } else {
            setTheme(mThemeId);
        }
    }
    super.onCreate(savedInstanceState);
}

通过 installViewFactory 这个方法调用了factory的设置

//android.support.v7.app.AppCompatDelegateImplV9.java
@Override
public void installViewFactory() {
    LayoutInflater layoutInflater = LayoutInflater.from(mContext);
    if (layoutInflater.getFactory() == null) {
        LayoutInflaterCompat.setFactory2(layoutInflater, this);
    }
}

LayoutInflaterCompat.setFactory2(layoutInflater, factory)的第二个参数就是需要实现 LayoutInflater.Factory2 的对象,这里传的是this,所以说看AppCompatDelegateImplV9的具体实现逻辑。

//android.support.v7.app.AppCompatDelegateImplV9.java
/**
 * From {@link LayoutInflater.Factory2}.
 */
@Override
public final View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
    // First let the Activity's Factory try and inflate the view
    final View view = callActivityOnCreateView(parent, name, context, attrs);
    if (view != null) {
        return view;
    }

    // If the Factory didn't handle it, let our createView() method try
    return createView(parent, name, context, attrs);
}

这里的到了一个新的发现,我觉得这个callActivityOnCreateView能进行皮肤缓存所有View,就是在activity里面实现onCreateView()方法,结果好像不打印子View了,不清楚什么原因 callActivityOnCreateView ->

看 createView 这个方法

//android.support.v7.app.AppCompatDelegateImplV9.java
public View createView(View parent, final String name, @NonNull Context context,
        @NonNull AttributeSet attrs) {
    
    if (mAppCompatViewInflater == null) {
        mAppCompatViewInflater = new AppCompatViewInflater();
    }
    //...

    return mAppCompatViewInflater.createView(parent, name, context, attrs, inheritContext,
            IS_PRE_LOLLIPOP, /* Only read android:theme pre-L (L+ handles this anyway) */
            true, /* Read read app:theme as a fallback at all times for legacy reasons */
            VectorEnabledTintResources.shouldBeUsed() /* Only tint wrap the context if enabled */
    );
}

AppCompatViewInflater:就是用来把AppCompatTextView AppCompatImageView等AppCompatXXView系列的进行替换创建。看AppCompatViewInflater # createView

//android.support.v7.app.AppCompatViewInflater
public final View createView(View parent, final String name, @NonNull Context context,
        @NonNull AttributeSet attrs, boolean inheritContext,
        boolean readAndroidTheme, boolean readAppTheme, boolean wrapContext) {
        
        //...略
    View view = null;

    // We need to 'inject' our tint aware Views in place of the standard framework versions
    switch (name) {
        case "TextView":
            view = new AppCompatTextView(context, attrs);
            break;
        case "ImageView":
            view = new AppCompatImageView(context, attrs);
            break;
            //...略
    }

    if (view == null && originalContext != context) {
        view = createViewFromTag(context, name, attrs);
    }

    if (view != null) {
        // If we have created a view, check its android:onClick
        checkOnClickListener(view, attrs);
    }

    return view;
}

如果不符合AppCompatXXView类的就只能进行反射去创建了,看createViewFromTag方法

//android.support.v7.app.AppCompatViewInflater

private static final String[] sClassPrefixList = {
            "android.widget.",
            "android.view.",
            "android.webkit."
    };


private View createViewFromTag(Context context, String name, AttributeSet attrs) {
        //...略
    try {
        mConstructorArgs[0] = context;
        mConstructorArgs[1] = attrs;

        //1 
        if (-1 == name.indexOf('.')) {
            for (int i = 0; i < sClassPrefixList.length; i++) {
                final View view = createView(context, name, sClassPrefixList[i]);
                if (view != null) {
                    return view;
                }
            }
            return null;
        } else {
         //2
            return createView(context, name, null);
        }
    }
    //...略
}

private View createView(Context context, String name, String prefix)
            throws ClassNotFoundException, InflateException {
        //..略
        //通过 2个参数的构造方法创建View对象
        Class<? extends View> clazz = context.getClassLoader().loadClass(
                prefix != null ? (prefix + name) : name).asSubclass(View.class);
                //...略
        constructor = clazz.getConstructor(sConstructorSignature);
        constructor.setAccessible(true);
        return constructor.newInstance(mConstructorArgs);
    }

1 如果是没带点的,就是像TextView等其他的系统类,通是sClassPrefixList这个数组遍历拼接上,进行反射创建View对象。

2 带了点的,就是一些三方的v7,v4,自定义View,都会带上包名,所以走第二步这个方法

以上,就基本知道View怎么来的了,换肤的话需要进行,把所有需要换肤的View保存起来,然后在换皮肤的时候,进行调用 setTextColor、setBackgroundResource, setImageResource 等。

不妨自己去代理View的创建过程,然后通过自己去实现一些View对应布局的View,这样类似 AppCompatXXView 这种变成自己的,如:SkinCompatXXView 这样,这些View都自己掌控了,加个换肤的方法 applySkin 就好实现了。

三、换肤资源怎么获取

上面以及得到了,所有的 可以进行具有换肤的View 对象

换肤对象:其实就是自己通过实现Factory2,然后在后续 LayoutInflater 流程

inflater() -> createViewFromTag -> createViewFromTag(5 params) -> tryCreateView -> Factory.onCreateView

最终调用自己这个Factory2#onCreateView(View, String, Context, AttributeSet),创建对应的SkinCompatXXView,在这过程中,我们可以顺便把这些对象缓存起来,为后续的资源获取后,进行调用setTextColor、setBackgroundResource, setImageResource 等做准备

方式一、加载assets下,通过apk打包好的资源包

//skin.support.load.SkinAssetsLoader
private String copySkinFromAssets(Context context, String name) {
    String skinPath = new File(SkinFileUtils.getSkinDir(context), name).getAbsolutePath();
    try {
        InputStream is = context.getAssets().open(
                SkinConstants.SKIN_DEPLOY_PATH + File.separator + name);
        OutputStream os = new FileOutputStream(skinPath);
        int byteCount;
        byte[] bytes = new byte[1024];
        while ((byteCount = is.read(bytes)) != -1) {
            os.write(bytes, 0, byteCount);
        }
        os.close();
        is.close();
    } catch (IOException e) {
        e.printStackTrace();
    }
    return skinPath;
}

//skin.support.utils.SkinFileUtils
public static String getSkinDir(Context context) {
        File skinDir = new File(getCacheDir(context), SkinConstants.SKIN_DEPLOY_PATH);
        if (!skinDir.exists()) {
            skinDir.mkdirs();
        }
        return skinDir.getAbsolutePath();
}

copySkinFromAssets 就是获取本地 assets下面的皮肤资源,然后拷贝到 cache目录下面

cache目录有两种情况

  1. 挂载sdcard的情况,且创建成功了,就是 sdcard/Android/data/${packageName}/skins下面
  2. sdcard挂载失败了或者没成功创建,就是在 data/data/${packageName}/skins 目录下面

皮肤资源已经放到了对应的目录,然后处理这个文件

//skin.support.SkinCompatManager
/**
 * 获取皮肤包包名.
 *
 * @param skinPkgPath sdcard中皮肤包路径.
 * @return
 */
public String getSkinPackageName(String skinPkgPath) {
    PackageManager mPm = mAppContext.getPackageManager();
    PackageInfo info = mPm.getPackageArchiveInfo(skinPkgPath, PackageManager.GET_ACTIVITIES);
    return info.packageName;
}

/**
 * 获取皮肤包资源{@link Resources}.
 *
 * @param skinPkgPath sdcard中皮肤包路径.
 * @return
 */
@Nullable
public Resources getSkinResources(String skinPkgPath) {
    try {
        PackageInfo packageInfo = mAppContext.getPackageManager().getPackageArchiveInfo(skinPkgPath, 0);
        packageInfo.applicationInfo.sourceDir = skinPkgPath;
        packageInfo.applicationInfo.publicSourceDir = skinPkgPath;
        Resources res = mAppContext.getPackageManager().getResourcesForApplication(packageInfo.applicationInfo);
        Resources superRes = mAppContext.getResources();
        return new Resources(res.getAssets(), superRes.getDisplayMetrics(), superRes.getConfiguration());
    } catch (Exception e) {
        e.printStackTrace();
    }
    return null;
}

通过上面的方法,获取到了皮肤资源中的Resources对象,后面换皮肤的时候,就通过这个对象,获取里面的皮肤资源

参考

https://www.jianshu.com/p/f0f3de2f63e3

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