Android实现低侵入轻量级内置换肤和外置皮肤包换肤

   换肤技术我一直都感觉很炫酷,毕竟这是一个看脸的时代,并且在某些程度上也能给用户带来更好的体验从而提升产品竞争力,闲话少絮,我们开始切入正题。

          我们来思考下换肤换肤,到底换的是什么?

          其实很简单无非就是颜色,图片,最多在加一个字体。

          那么怎么替换呢?或者是怎样的思路呢?

   我们会发现他们都是资源,也就是说这些我们是可以通过AssetManager拿到的,所以我们把一个module做成皮肤包(主要就是res目录下的文件),把它下载到本地然后用AssetManager去加载这个皮肤包的资源并替换就实现了我们的外置皮肤包换肤的需求,而内置换肤就更简单了因为所有资源都是放到项目中的,只要加载指定文件夹下的资源就好了,这种方式比较核心的就是无论是颜色还是图片,他们的资源名和对应的皮肤包(外置换肤)或文件夹中的资源名是一致的(通过资源名来匹配)。

          可能我总结的归纳的不好,大家一下看不太懂,下面我们就细致的实现下,首先来看内置换肤。

          先上张整个项目的结构图

          1.主App

     2.app依赖的库,将换肤相关的关键操作抽取出来,符合架构思想

   3.用于打包皮肤包APK(App类型的module),和前两者没有依赖关系是独立的

对于内置换肤我们只关心主app及skin_Library中的SkinActivity就可以了,因为换肤会涉及到极多Activity,所以我们把换肤操作都抽到SkinActivity这个父类中,让需要换肤的Activity继承他就好了。

1.内置换肤


public class SkinLocalActivity extends SkinActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_skin_local);
        
        //这是系统的Api,设置日间模式:MODE_NIGHT  夜间模式:MODE_NIGHT_NO
        //看到这里一定要回头去看下第一张图片,在res文件夹下,不太细心的小伙伴可能没有注意到
        //比平时的项目多了一个values-night的文件夹,当设置为夜间模式时,就会去加载这个文件夹
        //下的资源,同理如果涉及到了图片,可以自己新建一个drawable-night道理是一样的       
        //但要注意的是切换了日间夜间模式后需要view刷新,重新去加载一下资源下面会讲到
        AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO);
        }
    }

    // 点击事件
    public void dayOrNight(View view) {
        //得到当前皮肤模式
        int uiMode = getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK;

        switch (uiMode) {
            case Configuration.UI_MODE_NIGHT_NO:
                //父类方法
                setDayNightMode(AppCompatDelegate.MODE_NIGHT_YES);
                //将切换后的皮肤类型缓存,下次打开App时读取
                PreferencesUtils.putBoolean(this, "isNight", true);
                break;
            case Configuration.UI_MODE_NIGHT_YES:
                //父类方法
                setDayNightMode(AppCompatDelegate.MODE_NIGHT_NO);
                PreferencesUtils.putBoolean(this, "isNight", false);
                break;
            default:
                break;
        }
    }

    
    //父类重写方法,用于设置此Activty是否需要换肤

    @Override
    protected boolean openChangeSkin() {
        return true;
    }
}

布局文件

<?xml version="1.0" encoding="utf-8"?>
<com.example.skin.library.views.SkinnableLinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/commonTextColor"
    android:gravity="center"
    android:orientation="vertical"
    tools:context=".MainActivity">

    <com.example.skin.library.views.SkinnableTextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="30dp"
        android:text="同学们,大家好!"
        android:textColor="@color/commonTextColor1"
        android:textSize="30sp"
        android:textStyle="bold" />

    <com.example.skin.library.views.SkinnableButton
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="@color/commonTextColor"
        android:onClick="dayOrNight"
        android:padding="10dp"
        android:text="日间 / 夜间"
        android:textColor="@color/commonTextColor1"
        android:textSize="25sp" />
</com.netease.skin.library.views.SkinnableLinearLayout>

紧接着我们看下父类是怎么实现的

public class SkinActivity extends AppCompatActivity {

    private CustomAppCompatViewInflater viewInflater;

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
    }

    /**
     * @return 是否开启换肤,增加此开关是为了避免开发者误继承此父类,导致未知bug
     */
    protected boolean openChangeSkin() {
        return false;
    }

    /**
      * 根据类型进行换肤
      */
    protected void setDayNightMode(@AppCompatDelegate.NightMode int nightMode) {

        if (!openChangeSkin())   return;

        //兼容5.0及以上版本
        final boolean isPost21 = Build.VERSION.SDK_INT >= 21;
        //设置日间/夜间模式
        getDelegate().setLocalNightMode(nightMode);
        
        // 设置以下的颜色,不是太重点,稍后再看
        if (isPost21) {
            // 换状态栏
            StatusBarUtils.forStatusBar(this);
            // 换标题栏
            ActionBarUtils.forActionBar(this);
            // 换底部导航栏
            NavigationUtils.forNavigation(this);
        }

              
        // 重点来了!!!!!!
        // 布局里那么多的view都要换肤,就是从顶层Decorview从上往下遍历设置的 
        View decorView = getWindow().getDecorView();
        applyDayNightForView(decorView);
    }

    /**
     * 回调接口 给具体控件换肤操作
     */
    protected void applyDayNightForView(View view) {
        // 这里就是去填SkinLocalActivity 留下的坑,如何去刷新view,去重新加载资源
        // 在此我们是通过自定义view实现的,这个ViewsMatch是一个接口,自定义view都去实现这个接口 ,看到下面代码大家就理解了
        if (view instanceof ViewsMatch) {
            ViewsMatch viewsMatch = (ViewsMatch) view;
            viewsMatch.skinnableView();
        }

        // 当view是ViewGroup时,就去递归操作
        if (view instanceof ViewGroup) {
            ViewGroup parent = (ViewGroup) view;
            int childCount = parent.getChildCount();
            for (int i = 0; i < childCount; i++) {
                applyDayNightForView(parent.getChildAt(i));
            }
        }
    }
}

看下超级无敌简单的ViewsMatch接口

public interface ViewsMatch {

    /**
     * 控件换肤
     */
    void skinnableView();
}

在给大家看一个自定义view的例子

public class SkinnableButton extends AppCompatButton implements ViewsMatch {

    private AttrsBean attrsBean;

    public SkinnableButton(Context context) {
        this(context, null);
    }

    public SkinnableButton(Context context, AttributeSet attrs) {
        this(context, attrs, android.support.v7.appcompat.R.attr.buttonStyle);
    }

    public SkinnableButton(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);

        // 这是个优化操作,把view的属性存到集合,下次用到直接从集合中取就可以了
        attrsBean = new AttrsBean();

        // 根据自定义属性,匹配控件属性的类型集合,如:background + textColor
        TypedArray typedArray = context.obtainStyledAttributes(attrs,
                R.styleable.SkinnableButton,
                defStyleAttr, 0);
        // 存储到临时JavaBean对象
        attrsBean.saveViewResource(typedArray, R.styleable.SkinnableButton);
        // 这一句回收非常重要!obtainStyledAttributes()有语法提示!!
        typedArray.recycle();
    }

    @Override
    public void skinnableView() {
        // 根据自定义属性,获取styleable中的background属性
        int key = R.styleable.SkinnableButton[R.styleable.SkinnableButton_android_background];
        // 根据styleable获取控件某属性的resourceId
        int backgroundResourceId = attrsBean.getViewResource(key);
        if (backgroundResourceId > 0) {
            // 兼容包转换
            Drawable drawable = ContextCompat.getDrawable(getContext(), backgroundResourceId);
            // 控件自带api,这里不用setBackgroundColor()因为在9.0测试不通过
            // setBackgroundDrawable本来过时了,但是兼容包重写了方法
            setBackgroundDrawable(drawable);
        }

        // 根据自定义属性,获取styleable中的textColor属性
        key = R.styleable.SkinnableButton[R.styleable.SkinnableButton_android_textColor];
        int textColorResourceId = attrsBean.getViewResource(key);
        if (textColorResourceId > 0) {
            ColorStateList color = ContextCompat.getColorStateList(getContext(), textColorResourceId);
            setTextColor(color);
        }
    }
}

如此,大家可以去拓展所有你要用到的view,Textview,ImageView,LinearLayout,RelativeLayout等等,然后在布局中用自定义view就可以了,其实还有一种更符合架构的设计,就是当解析xml布局文件时,比如拿到Button节点我们给创建一个自定义的SkinnableButton而不是Button,这样的话其他的开发人员就不必关心去找他所需的自定义控件在哪里了,但我们在实现过程需要大家去阅读研究View加载的源码,现在我们来梳理一下。

1.说起解析xml布局毫无疑问要去看下setContentView(@LayoutRes int layoutResID)这个方法(不清楚布局文件加载流程点击这里),然后一步步跟进最终我们来到LayoutInflater的createViewFromTag方法。

View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
            boolean ignoreThemeAttr) {
      .......

        //我们只看重点,到最后xml里的view都是通过Factory2(或Factory)的onCreateView()方法创建的,那么Factory2(或Factory)又是在哪里设置的呢?
        try {
            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;
            }

            if (view == null && mPrivateFactory != null) {
                view = mPrivateFactory.onCreateView(parent, name, context, attrs);
            }

            if (view == null) {
                final Object lastContext = mConstructorArgs[0];
                mConstructorArgs[0] = context;
                try {
                    if (-1 == name.indexOf('.')) {
                        view = onCreateView(parent, name, attrs);
                    } else {
                        view = createView(name, null, attrs);
                    }
                } finally {
                    mConstructorArgs[0] = lastContext;
                }
            }

            return view;
        } catch (InflateException e) {
            throw e;

        } catch (ClassNotFoundException e) {
          .....

        } catch (Exception e) {
           .....
        }
    }

关于这个问题,其实我们是有迹可循的,

其一:在setContentView中Factory2对象已被使用必然要在setContentView方法调用之前找答案

其二:了解过Activity源码的小伙伴应该知道Activity就是Factory2接口的实现类

因此我们要到onCreate()方法中一探究竟了。

当来到AppCompatActivity的onCreate()方法时,我们找到了相应的操作

 protected void onCreate(@Nullable Bundle savedInstanceState) {
        AppCompatDelegate delegate = this.getDelegate();
        //就是这里
        delegate.installViewFactory();
        delegate.onCreate(savedInstanceState);
        ......
        super.onCreate(savedInstanceState);
    }

继续跟进,AppCompatDelegate是一个抽象类,点击左边的图标进到他的子类:AppCompatDelegateImpl

 public void installViewFactory() {
        LayoutInflater layoutInflater = LayoutInflater.from(this.mContext);
        //答案在此揭晓,当layoutInflater.getFactory() == null时进行了设置,
        //有的小伙伴要有疑问了,这个layoutInflater 不是在这里新创建的吗,给这个layoutInflater 设置了Factory2,
        //PhoneWindow中setContentView中的mLayoutInflater怎么会也设置了Factory2?
        if (layoutInflater.getFactory() == null) {
            //传的this是因为这个类也实现了Factory2接口,并实现了Factory2的方法,onCreateView()
            //setFactory2此方法中,factory = factory2,factory被赋值
            LayoutInflaterCompat.setFactory2(layoutInflater, this);
        } else if (!(layoutInflater.getFactory2() instanceof AppCompatDelegateImpl)) {
            //这里是核心,我们要做的就是在子类(SkinActivity)中设置Factory,然后走到这个分支,下面会提到
            Log.i("AppCompatDelegate", "The Activity's LayoutInflater already has a Factory installed so we can not install AppCompat's");
        }

    }

对于这个问题大家可以看下LayoutInflater的源码或LayoutInflater源码分析这篇文章,这里直接给大家结论:

除了第一次LayoutInflater.from(getBaseContext())创建了一个PhoneLayoutInflater实例,再调用

LayoutInflater layoutInflater = LayoutInflater.from(this);

通过现有的LayoutInflater创建一个新的LayoutInflater副本(LayoutInflater的cloneInContext()方法),唯一变化的地方是指向不同的上下文对象。

这次终于到了在上文梳理setContentView加载布局文件最后的createViewFromTag()方法中

mFactory2.onCreateView(parent, name, context, attrs);的实现位置了。

继续看AppCompatDelegateImpl类的Factory2接口的实现方法

    public View createView(View parent, String name, @NonNull Context context, @NonNull AttributeSet attrs) {
       
        .....
        //直接来到核心代码,继续进入下面的createView
        return this.mAppCompatViewInflater.createView(parent, name, context, attrs, inheritContext, IS_PRE_LOLLIPOP, true, VectorEnabledTintResources.shouldBeUsed());
    }

到此我们得到了最终的答案,是根据xml解析出的节点的name,来创建对应得view的,那我们重写这一块让他创建我们的自定义view不就可以了?

 final View createView(View parent, String name, @NonNull Context context, @NonNull AttributeSet attrs, boolean inheritContext, boolean readAndroidTheme, boolean readAppTheme, boolean wrapContext) {

        View view = null;
        byte var12 = -1;
        switch(name.hashCode()) {
        case -1946472170:
            if (name.equals("RatingBar")) {
                var12 = 11;
            }
            break;
        case -1455429095:
            if (name.equals("CheckedTextView")) {
                var12 = 8;
            }
            break;
        case -1346021293:
            if (name.equals("MultiAutoCompleteTextView")) {
                var12 = 10;
            }
            break;
        case -938935918:
            if (name.equals("TextView")) {
                var12 = 0;
            }
            break;
        case -937446323:
            if (name.equals("ImageButton")) {
                var12 = 5;
            }
            break;
        case -658531749:
            if (name.equals("SeekBar")) {
                var12 = 12;
            }
            break;
        case -339785223:
            if (name.equals("Spinner")) {
                var12 = 4;
            }
            break;
        case 776382189:
            if (name.equals("RadioButton")) {
                var12 = 7;
            }
            break;
        case 1125864064:
            if (name.equals("ImageView")) {
                var12 = 1;
            }
            break;
        case 1413872058:
            if (name.equals("AutoCompleteTextView")) {
                var12 = 9;
            }
            break;
        case 1601505219:
            if (name.equals("CheckBox")) {
                var12 = 6;
            }
            break;
        case 1666676343:
            if (name.equals("EditText")) {
                var12 = 3;
            }
            break;
        case 2001146706:
            if (name.equals("Button")) {
                var12 = 2;
            }
        }

        switch(var12) {
        case 0:
            view = this.createTextView(context, attrs);
            this.verifyNotNull((View)view, name);
            break;
        case 1:
            view = this.createImageView(context, attrs);
            this.verifyNotNull((View)view, name);
            break;
        case 2:
            view = this.createButton(context, attrs);
            this.verifyNotNull((View)view, name);
            break;
        case 3:
            view = this.createEditText(context, attrs);
            this.verifyNotNull((View)view, name);
            break;
        case 4:
            view = this.createSpinner(context, attrs);
            this.verifyNotNull((View)view, name);
            break;
        case 5:
            view = this.createImageButton(context, attrs);
            this.verifyNotNull((View)view, name);
            break;
        case 6:
            view = this.createCheckBox(context, attrs);
            this.verifyNotNull((View)view, name);
            break;
        case 7:
            view = this.createRadioButton(context, attrs);
            this.verifyNotNull((View)view, name);
            break;
        case 8:
            view = this.createCheckedTextView(context, attrs);
            this.verifyNotNull((View)view, name);
            break;
        case 9:
            view = this.createAutoCompleteTextView(context, attrs);
            this.verifyNotNull((View)view, name);
            break;
        case 10:
            view = this.createMultiAutoCompleteTextView(context, attrs);
            this.verifyNotNull((View)view, name);
            break;
        case 11:
            view = this.createRatingBar(context, attrs);
            this.verifyNotNull((View)view, name);
            break;
        case 12:
            view = this.createSeekBar(context, attrs);
            this.verifyNotNull((View)view, name);
            break;
        default:
            view = this.createView(context, name, attrs);
        }

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

        if (view != null) {
            this.checkOnClickListener((View)view, attrs);
        }

        return (View)view;
    }

OK,我们创建一个类继承AppCompatViewInflater

public final class CustomAppCompatViewInflater extends AppCompatViewInflater {

    private Context context;
    //控件名
    private String name;
    //上下文
    private AttributeSet attrs;

    public CustomAppCompatViewInflater(@NonNull Context context) {
        this.context = context;
    }

    public void setName(String name) {
        this.name = name;
    }

    public void setAttrs(AttributeSet attrs) {
        this.attrs = attrs;
    }

    public View autoMatch() {
        View view = null;
        switch (name) {
            case "LinearLayout":
                // view = super.createTextView(context, attrs); // 源码写法
                view = new SkinnableLinearLayout(context, attrs);
                this.verifyNotNull(view, name);
                break;
            case "RelativeLayout":
                view = new SkinnableRelativeLayout(context, attrs);
                this.verifyNotNull(view, name);
                break;
            case "TextView":
                view = new SkinnableTextView(context, attrs);
                this.verifyNotNull(view, name);
                break;
            case "ImageView":
                view = new SkinnableImageView(context, attrs);
                this.verifyNotNull(view, name);
                break;
            case "Button":
                view = new SkinnableButton(context, attrs);
                this.verifyNotNull(view, name);
                break;
        }

        return view;
    }


    /**
     * 校验控件不为空(源码方法,由于private修饰,只能复制过来了。为了代码健壮,可有可无)
     *
     * @param view 被校验控件,如:AppCompatTextView extends TextView(v7兼容包,兼容是重点!!!)
     * @param name 控件名,如:"ImageView"
     */
    private void verifyNotNull(View view, String name) {
        if (view == null) {
            throw new IllegalStateException(this.getClass().getName() + " asked to inflate view for <" + name + ">, but returned null");
        }
    }
}

然后在SkinActivity重写onCreateView方法,用我们自己布局加载器来创建view

private CustomAppCompatViewInflater viewInflater;

@Override
    public View onCreateView(String name, Context context, AttributeSet attrs) {

        if (openChangeSkin()) {
            if (viewInflater == null) {
                viewInflater = new CustomAppCompatViewInflater(context);
            }
            viewInflater.setName(name);
            viewInflater.setAttrs(attrs);
            return viewInflater.autoMatch();
        }
        return super.onCreateView(name, context, attrs);
    }

还有重要一步要做的就是要去拦截系统的布局加载方式

@Override
    protected void onCreate(Bundle savedInstanceState) {

        //拦截原生加载xml的方式,自己实现
        //原生加载xml是通过解析xml文件,根据如Textview则new一个AppCompatTextView,
        //我们要做的就是不new一个AppCompatTextView,而是new一个SkinTextview(自定义view),
        //这个在xml中写一个而是new一个SkinTextview是一致的
        //以此方法偷梁换柱来实现换肤
        //在上面的installViewFactory()有提到

        LayoutInflater layoutInflater = LayoutInflater.from(this);
      
        LayoutInflaterCompat.setFactory2(layoutInflater, this);

        super.onCreate(savedInstanceState);
    }

到此将布局文件中的自定义view改回系统提供的,运行完会发现效果是一样的。

在内置换肤的最后还有一点要注意,在AndroidManifest中的Activity标签下添加一个属性:

 <activity
            android:name=".SkinLocalActivity"
            //在不同品牌的手机上,不加这一行可能会导致状态栏、标题栏、底部导航栏换肤失败
            android:configChanges="uiMode" />
        <activity android:name=".SkinOutActivity" />

 

2.外置换肤

 其实外置换肤也是换汤不换药只不过是去加载皮肤资源包下的资源,如第三张图片所示,新建一个Module,然后把主app要换的资源都对应添加到这个module中,注意资源的名字一定是要一样的一一对应的。

我们来看下这个换肤的管理类

SkinManager
/**
 * 皮肤管理器
 * 加载应用资源(app内置:res/xxx) or 存储资源(下载皮肤包:net163.skin)
 */
public class SkinManager {
    private static SkinManager instance;
    private Application application;
    private Map<String, SkinCache> cacheSkin;
    private Resources appResources; // 用于加载app内置资源
    private Resources skinResources; // 用于加载皮肤包资源
    private String skinPackageName; // 皮肤包资源所在包名(注:皮肤包不在app内,也不限包名)
    private boolean isDefaultSkin = true; // 应用默认皮肤(app内置)
    private static final String ADD_ASSET_PATH = "addAssetPath"; // 方法名

    private SkinManager(Application application) {
        this.application = application;
        appResources = application.getResources();
        cacheSkin = new HashMap<>();
    }

    /**
     * 单例方法,目的是初始化app内置资源(越早越好,用户的操作可能是:换肤后的第2次冷启动)
     */
    public static void init(Application application) {
        if (instance == null) {
            synchronized (SkinManager.class) {
                if (instance == null) {
                    instance = new SkinManager(application);
                }
            }
        }
    }

    public static SkinManager getInstance() {
        return instance;
    }

    public boolean isDefaultSkin() {
        return isDefaultSkin;
    }

    //返回值情况特殊,可能是color/drawable/mipmap
    public Object getBackgroundOrSrc(int resourceId) {
        // 需要获取当前属性的类型名Resources.getResourceTypeName(resourceId)再判断
        String resourceTypeName = appResources.getResourceTypeName(resourceId);

        switch (resourceTypeName){
            case "color":
                return getColor(resourceId);
            case "mipmap":
            case "drawable":
                return getDrawableOrMipMap(resourceId);
        }
        return null;
    }

    public int getColor(int resourceId) {
        int ids = getSkinResourceIds(resourceId);
        return isDefaultSkin ? appResources.getColor(ids) : skinResources.getColor(ids);
    }

    // mipmap和drawable统一用法(待测)
    public Drawable getDrawableOrMipMap(int resourceId) {
        int ids = getSkinResourceIds(resourceId);
        return isDefaultSkin ? appResources.getDrawable(ids) : skinResources.getDrawable(ids);
    }

    public ColorStateList getColorStateList(int resourceId) {
        int ids = getSkinResourceIds(resourceId);
        return isDefaultSkin ? appResources.getColorStateList(ids) : skinResources.getColorStateList(ids);
    }

    public String getString(int resourceId) {
        int ids = getSkinResourceIds(resourceId);
        return isDefaultSkin ? appResources.getString(ids) : skinResources.getString(ids);
    }



    // 获得字体
    public Typeface getTypeface(int resourceId) {
        // 通过资源ID获取资源path,参考:resources.arsc资源映射表
        String skinTypefacePath = getString(resourceId);
        // 路径为空,使用系统默认字体
        if (TextUtils.isEmpty(skinTypefacePath)) return Typeface.DEFAULT;
        return isDefaultSkin ? Typeface.createFromAsset(appResources.getAssets(), skinTypefacePath)
                : Typeface.createFromAsset(skinResources.getAssets(), skinTypefacePath);
    }

    /**
     * 加载皮肤包资源
     *
     * @param skinPath 皮肤包路径,为空则加载app内置资源
     */
    public void loaderSkinResources(String skinPath) {
        //优化:如果没有皮肤包或换肤动作直接返回
        if (TextUtils.isEmpty(skinPath)){
            isDefaultSkin = true;
            return;
        }

        //优化:app冷启动,热启动可以取缓存对象
        if (cacheSkin.containsKey(skinPath)){
            isDefaultSkin = false;
            SkinCache skinCache = cacheSkin.get(skinPath);
            if (null!=skinCache){
                skinResources = skinCache.getSkinResources();
                skinPackageName = skinCache.getSkinPackageName();
                return;
            }
        }


        try {
            //创建资源管理器(此处不能用:application.getAssets())
            AssetManager assetManager = AssetManager.class.newInstance();
            //由于AssetManager中的addAssetPath和setApkAssets方法都被@hide,目前只能通过反射去执行方法
            Method addAssetPath = assetManager.getClass().getDeclaredMethod(ADD_ASSET_PATH,String.class);
            //设置私有方法可访问
            addAssetPath.setAccessible(true);
            // 执行addAssetPath方法
            addAssetPath.invoke(assetManager, skinPath);
            //==============================================================================
            // 如果还是担心@hide限制,可以反射addAssetPathInternal()方法,参考源码366行 + 387行
            //==============================================================================

            // 创建加载外部的皮肤包(net163.skin)文件Resources(注:依然是本应用加载)
            skinResources = new Resources(assetManager,appResources.getDisplayMetrics(),appResources.getConfiguration());
            //根据apk文件路径(皮肤包也是apk文件),获取应用的包名
            skinPackageName = application.getPackageManager().getPackageArchiveInfo(skinPath, PackageManager.GET_ACTIVITIES).packageName;
            // 无法获取皮肤包应用的包名,则加载app内置资源
            isDefaultSkin = TextUtils.isEmpty(skinPackageName);
            if (!isDefaultSkin) {
                cacheSkin.put(skinPath, new SkinCache(skinResources, skinPackageName));
            }

            Log.e("skinPackageName >>> ", skinPackageName);
        } catch (Exception e){
            e.printStackTrace();
            // 发生异常,预判:通过skinPath获取skinPacakageName失败!
            isDefaultSkin = true;
        }
    }

    /**
     * 参考:resources.arsc资源映射表
     * 通过ID值获取资源 Name 和 Type
     *
     * @param resourceId 资源ID值
     * @return 如果没有皮肤包则加载app内置资源ID,反之加载皮肤包指定资源ID
     */
    private int getSkinResourceIds(int resourceId) {
        // 优化:如果没有皮肤包或者没做换肤动作,直接返回app内置资源!
        if (isDefaultSkin) return resourceId;

        // 使用app内置资源加载,是因为内置资源与皮肤包资源一一对应(“netease_bg”, “drawable”)
        String resourceName = appResources.getResourceEntryName(resourceId);
        String resourceType = appResources.getResourceTypeName(resourceId);

        // 动态获取皮肤包内的指定资源ID
        // getResources().getIdentifier(“netease_bg”, “drawable”, “com.netease.skin.packages”);
        int skinResourceId = skinResources.getIdentifier(resourceName, resourceType, skinPackageName);

        // 源码1924行:(0 is not a valid resource ID.)
       
        return skinResourceId == 0 ? resourceId : skinResourceId;
    }
}


其核心思想就是,拿到自定义view中的背景、颜色等属性的id,进而得到这些属性的类型(drawable、colors、mipmap等)和资源的名字(abc.png    <color name="skin_textColor">#FFFFFF</color>),根据二者去拿到皮肤包里对应得资源id,然后将这个资源id赋值给自定义view的背景和颜色。

在Application的onCreate中初始化SkinManager

SkinManager.init(this);

在Activity中使用

/**
 * 如果图标有固定的尺寸,不需要更改,那么drawable更加适合
 * 如果需要变大变小变大变小的,有动画的,放在mipmap中能有更高的质量
 */
public class SkinOutActivity extends SkinActivity {

    private String skinPath;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        // File.separator含义:拼接 /
        // 资源包路径,(按自己需求)
        skinPath = Environment.getExternalStorageDirectory().getAbsolutePath()
                + File.separator + "net163.skin";

        // 运行时权限申请(6.0+)
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            String[] perms = {Manifest.permission.WRITE_EXTERNAL_STORAGE};
            if (checkSelfPermission(perms[0]) == PackageManager.PERMISSION_DENIED) {
                requestPermissions(perms, 200);
            }
        }

        if (("net163").equals(PreferencesUtils.getString(this, "currentSkin"))) {
            skinDynamic(skinPath, R.color.skin_item_color);
        } else {
            defaultSkin(R.color.colorPrimary);
        }
    }

    // 换肤按钮(api限制:5.0版本)
    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    public void skinDynamic(View view) {
        // 真实项目中:需要先判断当前皮肤,避免重复操作!
        if (!("net163").equals(PreferencesUtils.getString(this, "currentSkin"))) {
            Log.e("netease >>> ", "-------------start-------------");
            long start = System.currentTimeMillis();

            skinDynamic(skinPath, R.color.skin_item_color);
            PreferencesUtils.putString(this, "currentSkin", "net163");

            long end = System.currentTimeMillis() - start;
            Log.e("netease >>> ", "换肤耗时(毫秒):" + end);
            Log.e("netease >>> ", "-------------end---------------");
        }
    }

    // 默认按钮(api限制:5.0版本)
    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    public void skinDefault(View view) {
        if (!("default").equals(PreferencesUtils.getString(this, "currentSkin"))) {
            Log.e("netease >>> ", "-------------start-------------");
            long start = System.currentTimeMillis();

            defaultSkin(R.color.colorPrimary);
            PreferencesUtils.putString(this, "currentSkin", "default");

            long end = System.currentTimeMillis() - start;
            Log.e("netease >>> ", "还原耗时(毫秒):" + end);
            Log.e("netease >>> ", "-------------end---------------");
        }
    }

    //开启换肤
    @Override
    protected boolean openChangeSkin() {
        return true;
    }

    public void jumpSelf(View view) {
        startActivity(new Intent(this,ThreeActivity.class));
    }
}

将皮肤包module打包成Apk,重命名net163.skin(自己随意),并将其放到手机sdcard中(位置随意,与代码中取文件的位置对应上就可以),到此两种换肤全部完成。本篇文章主要是核心内容,完整项目可由以下链接获取。

换肤demo

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值