美文网首页框架【库】UI效果仿写
ThemeSkinning——Android换肤库源码学习

ThemeSkinning——Android换肤库源码学习

作者: leilifengxingmw | 来源:发表于2019-06-16 12:19 被阅读25次

详细的使用方式请参考 ThemeSkinning,本文的核心是学习换肤实现原理,不会详细介绍这个库如何使用,该库中关于夜间模式的内容和改变字体相关内容也暂时不去关心。

应用的assets目录下的字体文件和皮肤包如下图所示。

asset.png

开始分析

首先定义用于观察者接口,需要换肤的观察者可以实现这个接口

public interface ISkinUpdate {
    void onThemeUpdate();
}

默认情况下支持的换肤属性在AttrFactory中定义了。

public class AttrFactory {

    private static HashMap<String, SkinAttr> sSupportAttr = new HashMap<>();

    static {
        sSupportAttr.put("background", new BackgroundAttr());
        sSupportAttr.put("textColor", new TextColorAttr());
        sSupportAttr.put("src", new ImageViewSrcAttr());
    }
//...

}


首先应用的Application要继承SkinBaseApplication,在启动的时候加载皮肤文件。

public class SkinBaseApplication extends Application {

    @Override
    public void onCreate() {
        super.onCreate();
        SkinManager.getInstance().init(this);
    }
}

SkinManager的init方法

 public void init(Context ctx) {
        context = ctx.getApplicationContext();
        //设置字体
        TypefaceUtils.CURRENT_TYPEFACE = TypefaceUtils.getTypeface(context);
        //注释1处
        setUpSkinFile(context);
        //...
        String skin = SkinConfig.getCustomSkinPath(context);
        if (SkinConfig.isDefaultSkin(context)) {//使用默认皮肤,则不需要加载皮肤文件
            return;
        }
        //注释2处,加载皮肤文件
        loadSkin(skin, null);
    }
}

在注释1处,调用setUpSkinFile方法,将assets/skin目录下的皮肤复制到指定目录

private void setUpSkinFile(Context context) {
    try {
        String[] skinFiles = context.getAssets().list(SkinConfig.SKIN_DIR_NAME);
        for (String fileName : skinFiles) {
            File file = new File(SkinFileUtils.getSkinDir(context), fileName);
            if (!file.exists()) {//如果文件不存在,则拷贝
                SkinFileUtils.copySkinAssetsToDir(context, fileName, 
                    SkinFileUtils.getSkinDir(context));
            }
        }
    } catch (IOException e) {
        e.printStackTrace();
    }
}

注释2处,加载皮肤文件,使用AsyncTask来实现。loadSkin方法部分代码

@Override
protected Resources doInBackground(String... params) {//后台加载
    String skinPkgPath = SkinFileUtils.getSkinDir(context) + File.separator + params[0];
    // skinPackagePath:/storage/emulated/0/Android/data/solid.ren.themeskinning/cache/skin/theme-20180417.skin
    SkinL.i(TAG, "skinPackagePath:" + skinPkgPath);
    File file = new File(skinPkgPath);
    if (!file.exists()) {
        return null;
    }
    /**
     * 把路径在
     * /storage/emulated/0/Android/data/solid.ren.themeskinning/cache/skin/xxx.skin
     * 的文件作为asset的一部分
     */
    PackageManager mPm = context.getPackageManager();
    PackageInfo mInfo = mPm.getPackageArchiveInfo(skinPkgPath, PackageManager.GET_ACTIVITIES);
    //保存皮肤包的包名
    skinPackageName = mInfo.packageName;
    AssetManager assetManager = AssetManager.class.newInstance();
    Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
    addAssetPath.invoke(assetManager, skinPkgPath);

    /**
     * 根据asset来获取Resources
     */
    Resources superRes = context.getResources();
    Resources skinResource = ResourcesCompat.getResources(assetManager, superRes.getDisplayMetrics(), superRes.getConfiguration());
    //保存当前的皮肤路径
    SkinConfig.saveSkinPath(context, params[0]);
    isDefaultSkin = false;
    //返回新的Resources
    return skinResource;
}

@Override
protected void onPostExecute(Resources result) {
    mResources = result;
    if (mResources != null) {
        //...
        //注释1处,通知变更皮肤
        notifySkinUpdate();
    } 
}

上面代码主题流程就是把应用cache/skin/目录下的皮肤包作为asset的一部分,然后
从asset中加载皮肤包中的Resources,这个Resources包含了皮肤包中的所有资源信息。然后通知变更皮肤。

我们看一下注释1处,调用了notifySkinUpdate方法。

@Override
public void notifySkinUpdate() {
    if (mSkinObservers != null) {//如果存在需要换肤的观察者,就通知观察者
        for (ISkinUpdate observer : mSkinObservers) {
            observer.onThemeUpdate();
        }
    }
}

这个方法是在ISkinLoader接口中定义的。

//用来添加、删除、通知需要换肤的观察者
public interface ISkinLoader {
    //观察者的类型是ISkinUpdate
    void attach(ISkinUpdate observer);

    void detach(ISkinUpdate observer);

    void notifySkinUpdate();
}

SkinBaseActivity实现了ISkinUpdate接口,需要实现换肤的功能可以直接继承SkinBaseActivity。

SkinBaseActivity部分代码

    //SkinInflaterFactory
    private SkinInflaterFactory mSkinInflaterFactory;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        mSkinInflaterFactory = new SkinInflaterFactory(this);
        //设置setFactory2为mSkinInflaterFactory,使用mSkinInflaterFactory来创建view
        LayoutInflaterCompat.setFactory2(getLayoutInflater(), mSkinInflaterFactory);
        super.onCreate(savedInstanceState);
        changeStatusColor();
    }

    @Override
    public void onThemeUpdate() {
        mSkinInflaterFactory.applySkin();
    }

接下来看SkinInflaterFactory这个类是实现换肤的关键。我们创建View靠的就是这个类。

SkinInflaterFactory的四个参数的onCreateView方法

    @Override
    public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
        //注释1处,
        boolean isSkinEnable = attrs.getAttributeBooleanValue(SkinConfig.NAMESPACE, SkinConfig.ATTR_SKIN_ENABLE, false);
        AppCompatDelegate delegate = mAppCompatActivity.getDelegate();
        //注释2处
        View view = delegate.createView(parent, name, context, attrs);
        //...
        //支持换肤
        if (isSkinEnable || SkinConfig.isGlobalSkinApply()) {
            if (view == null) {//注释3处
                view = ViewProducer.createViewFromTag(context, name, attrs);
            }
            if (view == null) {
                return null;
            }
            //注释4处
            parseSkinAttr(context, attrs, view);
        }
        return view;
    }

在注释1处,如果我们需要为布局中的某些view提供换肤功能的时候,可以这样


single_change_skin.png

这样,只有tv_text_color会实现换肤操作。

注释2处和注释3处先来创建view。我们不去研究其中的细节。
注释4处应用皮肤属性。

    private void parseSkinAttr(Context context, AttributeSet attrs, View view) {
        //保存所有的换肤属性
        List<SkinAttr> viewAttrs = new ArrayList<>();
        for (int i = 0; i < attrs.getAttributeCount(); i++) {
            //注释1处如果给view设置了style
            if ("style".equals(attrName)) {
                //获取 textColor ,background 和 src属性值
                int[] skinAttrs = new int[]{android.R.attr.textColor, android.R.attr.background,android.R.attr.src};
                TypedArray a = context.getTheme().obtainStyledAttributes(attrs, skinAttrs, 0, 0);
                int textColorId = a.getResourceId(0, -1);
                int backgroundId = a.getResourceId(1, -1);
                int srcId = a.getResourceId(2, -1);

                if (textColorId != -1) {
                    String entryName = context.getResources().getResourceEntryName(textColorId);
                    String typeName = context.getResources().getResourceTypeName(textColorId);
                    SkinAttr skinAttr = AttrFactory.get("textColor", textColorId, entryName, typeName);
                    //添加color属性
                    if (skinAttr != null) {
                        viewAttrs.add(skinAttr);
                    }
                }
                if (backgroundId != -1) {
                    String entryName = context.getResources().getResourceEntryName(backgroundId);
                    String typeName = context.getResources().getResourceTypeName(backgroundId);
                    SkinAttr skinAttr = AttrFactory.get("background", backgroundId, entryName, typeName);
                   //保存background属性
                    if (skinAttr != null) {
                        viewAttrs.add(skinAttr);
                    }
                }
                
                if (srcId != -1) {
                    String entryName = context.getResources().getResourceEntryName(srcId);
                    String typeName = context.getResources().getResourceTypeName(srcId);
                    SkinAttr skinAttr = AttrFactory.get("src", srcId, entryName, typeName);
                    SkinL.w(TAG, "    srcId in style is supported:" + "\n" +
                            "    resource id:" + backgroundId + "\n" +
                            "    attrName:" + attrName + "\n" +
                            "    attrValue:" + attrValue + "\n" +
                            "    entryName:" + entryName + "\n" +
                            "    typeName:" + typeName);
                    if (skinAttr != null) {
                        viewAttrs.add(skinAttr);
                    }

                }
                a.recycle();
                continue;
            }
            //注释2处,换肤支持并以“@”开头的属性值,例如@color/red
            if (AttrFactory.isSupportedAttr(attrName) && attrValue.startsWith("@")) {
                try {
                    // //获取resource id,去掉开头的"@"
                    int id = Integer.parseInt(attrValue.substring(1));
                    if (id == 0) {
                        continue;
                    }
                    //entryName,eg:text_color_selector
                    String entryName = context.getResources().getResourceEntryName(id);
                    //typeName,eg:color、drawable
                    String typeName = context.getResources().getResourceTypeName(id);
                    SkinAttr mSkinAttr = AttrFactory.get(attrName, id, entryName, typeName);
                    //添加换肤属性
                    if (mSkinAttr != null) {
                        viewAttrs.add(mSkinAttr);
                    }
                } catch (NumberFormatException e) {
                    SkinL.e(TAG, e.toString());
                }
            }
        }
        //注释3处,如果换肤属性不为null,构建SkinItem
        if (!SkinListUtils.isEmpty(viewAttrs)) {
            SkinItem skinItem = new SkinItem();
            //注释4处,注意,我们这里把view保存了起来,
            skinItem.view = view;
            skinItem.attrs = viewAttrs;
            mSkinItemMap.put(skinItem.view, skinItem);
            if (SkinManager.getInstance().isExternalSkin() ||
                    SkinManager.getInstance().isNightMode()) {
                //注释5处,换肤
                skinItem.apply();
            }
        }
    }

注释1处,获取style中的 textColor , background ,src属性值

      <item name="android:textColor">@color/item_tv_title_color</item>
       <item name="android:background">@drawable/ic_homepage_car</item>
       <item name="android:src">@drawable/ic_homepage_car</item>

//注释2处,获取换肤支持并以“@”开头的属性值

android:textColor="@color/text_color_black"
android:background="@color/colorPrimary"
android:src="@drawable/ic_homepage_car"

注释3处,如果换肤属性不为null,构建SkinItem。
注释4处,注意注意,我们这里把view保存到了mSkinItemMap中,用于后来的动态换肤。并且我们要注意在合适的时机清除这些view避免内存泄漏。

注释5处,换肤。

public class SkinItem {

    public View view;

    public List<SkinAttr> attrs;

    public SkinItem() {
        attrs = new ArrayList<>();
    }

    public void apply() {
        if (SkinListUtils.isEmpty(attrs)) {
            return;
        }
        for (SkinAttr at : attrs) {
            at.apply(view);
        }
    }
//...
}

SkinAttr是一个抽象类,默认的实现类如下

BackgroundAttr

public class BackgroundAttr extends SkinAttr {

    @Override
    protected void applySkin(View view) {
        if (isColor()) {
            int color = SkinResourcesUtils.getColor(attrValueRefId);
            view.setBackgroundColor(color);
        } else if (isDrawable()) {
            Drawable bg = SkinResourcesUtils.getDrawable(attrValueRefId);
            view.setBackgroundDrawable(bg);
        }
    }
    //...
}

ImageViewSrcAttr

public class ImageViewSrcAttr extends SkinAttr {
    @Override
    protected void applySkin(View view) {
        if (view instanceof ImageView) {
            ImageView iv = (ImageView) view;
            if (isDrawable()) {
                iv.setImageDrawable(SkinResourcesUtils.getDrawable(attrValueRefId));
            } else if (isColor()) {
                iv.setImageDrawable(new ColorDrawable(SkinResourcesUtils.getColor(attrValueRefId)));
            }
        }
    }
}

TextColorAttr

public class TextColorAttr extends SkinAttr {

    @Override
    protected void applySkin(View view) {
        if (view instanceof TextView) {
            TextView tv = (TextView) view;
            if (isColor()) {
                tv.setTextColor(SkinResourcesUtils.getColorStateList(attrValueRefId));
            }
        }
    }
    //...
}

这个三个属性内部都是使用SkinResourcesUtils来获取属性值。
我们看一下SkinResourcesUtils的getColor方法。

public static int getColor(int resId) {
        //调用SkinManager的getColor方法。
        return SkinManager.getInstance().getColor(resId);
    }

SkinManager的getColor方法。

public int getColor(int resId) {
        //获取原始color
        int originColor = ContextCompat.getColor(context, resId);
        if (mResources == null || isDefaultSkin) {//如果不需要换肤,就直接返回
            return originColor;
        }
        //下面这些操作获取皮肤包里的color
        String resName = context.getResources().getResourceEntryName(resId);
        int trueResId = mResources.getIdentifier(resName, "color", skinPackageName);
        Log.d(TAG, "\ngetColor: resId=" + resId + "\nresName=" + resName + "\ntrueResId=" + trueResId + "\nskinPackageName=" + skinPackageName);
        int trueColor;
        if (trueResId == 0) {
            trueColor = originColor;
        } else {
            trueColor = mResources.getColor(trueResId);
        }
        return trueColor;
    }

SkinInflaterFactory的applySkin方法

public void applySkin() {
        //如果mSkinItemMap为空则返回。
        if (mSkinItemMap.isEmpty()) {
            return;
        }
        for (View view : mSkinItemMap.keySet()) {
            if (view == null) {
                continue;
            }
            mSkinItemMap.get(view).apply();
        }
    }

自定义换肤属性

该库默认支持的换肤属性只有 background,textColor,src,如果需要其他的换肤属性,就需要自己定义了。
那么如何自定义呢?举个例子
TabLayout大家应该都用过吧。它下面会有一个指示器,当我们换肤的时候也希望这个指示器的颜色也跟着更改。

  1. 新建TabLayoutIndicatorAttr继承SkinAttr
public class TabLayoutIndicatorAttr extends SkinAttr {

    @Override
    protected void applySkin(View view) {
        if (view instanceof TabLayout) {
            TabLayout tl = (TabLayout) view;
            if (isColor()) {//表示属性值类型是color类型
                //获取颜色
                int color = SkinResourcesUtils.getColor(attrValueRefId);
                //设置指示器的颜色
                tl.setSelectedTabIndicatorColor(color);
            }
        }
    }
}
  1. 然后将TabLayoutIndicatorAttr添加到AttrFactory中
SkinConfig.addSupportAttr("tabIndicatorColor", new TabLayoutIndicatorAttr());
AttrFactory.addSupportAttr(attrName, skinAttr);
public static void addSupportAttr(String attrName, SkinAttr skinAttr) {
        sSupportAttr.put(attrName, skinAttr);
    }

这个时候SkinInflaterFactory的parseSkinAttr方法中

 if (AttrFactory.isSupportedAttr(attrName) && attrValue.startsWith("@")) {
        //...
}

这时候就会处理app:tabIndicatorColor="@color/colorPrimaryDark"属性,并添加到viewAttrs中。这样就可以了。

动态添加view支持换肤

先看定义的接口,需要动态添加view并支持换肤可以实现这个接口

public interface IDynamicNewView {
    //添加多个换肤属性
    void dynamicAddView(View view, List<DynamicAttr> pDAttrs);
    //添加一个换肤属性
    void dynamicAddView(View view, String attrName, int attrValueResId);
 
}

SkinBaseFragment和SkinBaseActivity都实现了这个接口,而SkinBaseFragment内部还是通过它的宿主Activity来添加View的。

public class SkinBaseFragment extends Fragment implements IDynamicNewView {

    private IDynamicNewView mIDynamicNewView;

    @Override
    public void onAttach(Context context) {
        super.onAttach(context);
        try {
            //为mIDynamicNewView赋值
            mIDynamicNewView = (IDynamicNewView) context;
        } catch (ClassCastException e) {
            mIDynamicNewView = null;
        }
    }

    @Override
    public final void dynamicAddView(View view, List<DynamicAttr> pDAttrs) {
        if (mIDynamicNewView == null) {
            throw new RuntimeException("IDynamicNewView should be implements !");
        } else {
            mIDynamicNewView.dynamicAddView(view, pDAttrs);
        }
    }

    @Override
    public final void dynamicAddView(View view, String attrName, int attrValueResId) {
        mIDynamicNewView.dynamicAddView(view, attrName, attrValueResId);
    }

    //...
}

新建一个DynamicAddFragment来做测试

public class DynamicAddFragment extends SkinBaseFragment {

    private LinearLayout ll_dynamic_view;

    @Nullable
    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container, 
        Bundle savedInstanceState) {
        View view = inflater.inflate(R.layout.fragment_dynamic_add, container, false);
        ll_dynamic_view = view.findViewById(R.id.ll_dynamic_view);    
        createDynamicView();
        return view;
    }

  private void createDynamicView() {
        ImageView imageView = new ImageView(getContext());
        imageView.setBackgroundResource(R.mipmap.mipmap_img);
        imageView.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
        dynamicAddView(imageView, "background", R.mipmap.mipmap_img);
        ll_dynamic_view.addView(imageView);

        List<DynamicAttr> attrList = new ArrayList<>(2);
        attrList.add(new DynamicAttr("textColor", R.color.item_tv_title_color));
        attrList.add(new DynamicAttr("background", R.color.item_tv_title_background));
        for (int i = 0; i < 10; i++) {
            TextView textView1 = new TextView(getContext());
            textView1.setText("我是动态创建的TextView" + i + ",我也可以换肤");
            textView1.setTextColor(getResources().getColor(R.color.item_tv_title_color));
            ViewGroup.MarginLayoutParams params = new ViewGroup.MarginLayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
            params.setMargins(20, 20, 20, 20);
            textView1.setLayoutParams(params);
            //添加多个属性
            dynamicAddView(textView1, attrList);
            ll_dynamic_view.addView(textView1);
            dynamicAddFontView(textView1);
        }
    }

    //...
}

SkinBaseActivity部分代码

 @Override
    public void dynamicAddView(View view, List<DynamicAttr> pDAttrs) {
        mSkinInflaterFactory.dynamicAddSkinEnableView(this, view, pDAttrs);
    }

    @Override
    public void dynamicAddView(View view, String attrName, int attrValueResId) {
        mSkinInflaterFactory.dynamicAddSkinEnableView(this, view, attrName, attrValueResId);
    }

可以看到在SkinBaseActivity内部是通过mSkinInflaterFactory来动态添加支持换肤的属性的view。

我们看一下

public void dynamicAddSkinEnableView(Context context, View view, List<DynamicAttr> attrs) {
        //存放换肤属性列表
        List<SkinAttr> viewAttrs = new ArrayList<>();
        SkinItem skinItem = new SkinItem();
        //保存view
        skinItem.view = view;

        for (DynamicAttr dAttr : attrs) {
            int id = dAttr.refResId;
            String entryName = context.getResources().getResourceEntryName(id);
            String typeName = context.getResources().getResourceTypeName(id);
            SkinAttr mSkinAttr = AttrFactory.get(dAttr.attrName, id, entryName, typeName);
            //换肤属性
            viewAttrs.add(mSkinAttr);
        }

        skinItem.attrs = viewAttrs;
        //应用属性
        skinItem.apply();
        //添加到mSkinItemMap中去。
        addSkinView(skinItem);
    }

注意要在适当的时机移除动态添加的view

public class SkinBaseFragment extends Fragment implements IDynamicNewView {

 @Override
    public void onDestroyView() {
        removeAllView(getView());
        super.onDestroyView();
    }

    protected void removeAllView(View v) {
        if (v instanceof ViewGroup) {
            ViewGroup viewGroup = (ViewGroup) v;
            for (int i = 0; i < viewGroup.getChildCount(); i++) {
                removeAllView(viewGroup.getChildAt(i));
            }
            removeViewInSkinInflaterFactory(v);
        } else {
            removeViewInSkinInflaterFactory(v);
        }
    }

    private void removeViewInSkinInflaterFactory(View v) {
        if (getSkinInflaterFactory() != null) {
            //此方法用于Activity中Fragment销毁的时候,移除Fragment中的View
            getSkinInflaterFactory().removeSkinView(v);
        }
    }

}

SkinInflaterFactory的removeSkinView方法

public void removeSkinView(View view) {
        SkinL.i(TAG, "removeSkinView:" + view);
        SkinItem skinItem = mSkinItemMap.remove(view);
        if (skinItem != null) {
            SkinL.w(TAG, "removeSkinView from mSkinItemMap:" + skinItem.view);
        }
    }

总结一下换肤的主要流程

  1. 创建一个Android phone&Tablet Module类型的皮肤包。这个包里面只有颜色和图片等资源文件,没有类文件。这些资源文件的名字要和app的资源文件名字一样。
  2. 将这个皮肤包打包成apk,然后重命名为.skin结尾的文件(例如theme-20171126.skin)放入app的assets文件夹下。
  3. 在应用启动的时候,将skin文件复制到app的缓存目录下面(例如/storage/emulated/0/Android/data/solid.ren.themeskinning/cache/skin/xxx.skin)。然后将这个文件作为asset的一部分。
  4. 根据asset来获取皮肤包里面的Resources。
  5. 自定义SkinInflaterFactory用来创建view。在创建view的过程中动态保存可以换肤的属性集合,并把换肤属性集合和view实例保存在一个map中。
  6. 当用户调用换肤方法的时候,则遍历换肤属性集合,将属性值设置给view。
  7. 在适当的时机比如Activity finish或者fragment ondestroyView的时候清除map里面保存的换肤属性集合和view实例。

参考链接

  1. Android主题换肤 无缝切换
  2. ThemeSkinning

相关文章

网友评论

    本文标题:ThemeSkinning——Android换肤库源码学习

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