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

作者: 向前的zz | 来源:发表于2020-05-25 11:16 被阅读0次

    前言

    参照了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

    相关文章

      网友评论

        本文标题:Android切换皮肤原理的一些理解

        本文链接:https://www.haomeiwen.com/subject/ahzeahtx.html