移动架构04-动态换肤框架

作者: 最爱的火 | 来源:发表于2018-06-04 14:13 被阅读5次

移动架构04-动态换肤框架

一、前言

换肤就是修改app的样式(包括文字、颜色、背景等),通常用来提升用户体验。

换肤方式分为内置换肤和动态换肤。

内置换肤是在Apk包中存在多种资源(图片、颜色值)用于换肤时候切换。缺点是自由度低,apk文件大。 一般用于没有其他需求的日间/夜间模式app 。

动态换肤是通过运行时动态加载皮肤包。比较典型的是高德地图和网易云音乐。

高德地图是通过更新配置文件来替换样式。适用于简单的换肤,如:修改颜色。

网易云音乐是通过更换完整的皮肤包(APK文件)来替换样式。功能最强大,同时最占资源。

二、动态换肤应用

先说说这个框架怎么用?

1、基础应用

应用很简单,分为初始化和设置皮肤两步。

初始化需要在Application中进行。

public class MyApplication extends android.app.Application {

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

然后在Activity中设置皮肤。设置皮肤前,需要下载皮肤。这里就省略下载的步骤了。

/**
 * 一键换肤demo
 */
public class SkinActivity extends Activity {
    ...
    /**
     * 下载皮肤包
     */
    public void downloadSkin() {
        newPath = new File(getFilesDir(), assetPath).getAbsolutePath();
        ...
    }

    /**
     * 换皮肤
     *
     * @param view
     */
    public void change(View view) {
        SkinManager.getInstance().loadSkin(newPath);
    }

    /**
     * 还原皮肤
     *
     * @param view
     */
    public void restore(View view) {
        SkinManager.getInstance().loadSkin(null);
    }
}

注意:皮肤包就是一个APK文件,将一个没有代码的Module通过Android Studio的Build-Build APK(s)工具生产APK文件即可。皮肤包的资源属性名要与APP的资源属性名保持一致。

2、状态栏和导航栏

在5.0以上版本,状态栏和导航栏属性可以定义在主题中,设置属性后,在皮肤包中配置同名资源可以实现状态栏换肤。

为了提高兼容性,在vaule-v21目录中创建styles.xml:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <style name="AppTheme" parent="BaseTheme">
        <!-- Customize your theme here. -->
        <item name="android:statusBarColor">@color/colorPrimaryDark</item>
        <item name="android:navigationBarColor">@color/colorPrimaryDark</item>
    </style>
</resources>

然后在皮肤包中设置colorPrimaryDark资源就OK了。

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="colorPrimary">#1F1F1F</color>
</resources>

3、字体

设置全局字体,在Theme中设置skinTypeface属性,值为assets中字体的路径:

<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
    ...
    <item name="skinTypeface">@string/typeface1</item>
</style>

设置特定字体,在布局文件中设置skinTypeface属性,值为assets中字体的路径:

<TextView
    skinTypeface="@string/typeface2"
    ...
    tools:ignore="MissingPrefix" />

例如:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string name="typeface1">font/global.ttf</string>
    <string name="typeface2">font/specified.ttf</string>
</resources>

4、自定义控件

对于自定义的控件的支持性不够,需要实现SkinViewSupport接口,在换肤时,调用其applySkin(),从而修改样式。

public class CircleView extends View implements SkinViewSupport {
    ...
    @Override
    public void applySkin() {
        if (corcleColorResId != 0) {
            int color = SkinResources.getInstance().getColor(corcleColorResId);
            setCorcleColor(color);
        }
    }
}

三、动态换肤原理

本文采用网易云音乐的动态换肤方式。

换肤的本质就是修改样式。

首先,哪些View需要修改样式?

我们可以在Activity创建时,筛选指定类型和属性的View,并缓存下来。当指定皮肤包后,就可筛选这些View的样式。

然后,改成什么样式?

皮肤包就是APK文件,加载到内存后就可以获取其资源。通过当前View的资源ID从皮肤包中获取对应资源,就是需要修改的样式了。

四、动态换肤实现

动态换肤的实现分为获取View、获取样式、应用样式3步。

1、获取View

动态换肤框架是针对整个应用的,也就是要修改所有Activity的样式。通过Application.ActivityLifecycleCallbacks监听所有的Activity,在Activity创建时获取View。

class SkinActivityLifeCallback implements Application.ActivityLifecycleCallbacks {
    ...
    @Override
    public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
        //获取Activity的布局加载器
        LayoutInflater layoutInflater = LayoutInflater.from(activity);
        try {
            //将布局加载器的mFactorySet属性为false,这样就会使用Factory2来加载view
            Field field = LayoutInflater.class.getDeclaredField("mFactorySet");
            field.setAccessible(true);
            field.setBoolean(layoutInflater, false);
        } catch (Exception e) {
            e.printStackTrace();
        }
        SkinLayoutFactory skinLayoutFactory = new SkinLayoutFactory();
        //兼容低版本的写法:设置Factory2 
        LayoutInflaterCompat.setFactory2(layoutInflater, skinLayoutFactory);
        //用于手动换肤
        SkinManager.getInstance().addObserver(skinLayoutFactory);
        skinLayoutFactorys.put(activity, skinLayoutFactory);
    }
    ...
}

Activity是通过LayoutInflater来创建View的,LayoutInflater会优先使用Factory2来创建View。而这个Factory2是可以指定的,所以使用自定义的Factory2就可以获取View并设置样式了。

class SkinLayoutFactory implements LayoutInflater.Factory2, Observer {
    ...
    @Override
    public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
        View view = createViewFromTag(name, context, attrs);
        if (view == null) {
            view = createView(name, context, attrs);
        }
        //筛选需要换肤的View
        skinAttribute.load(view, attrs);
        return view;
    }
    ...
}

并不是所有的View都需要换肤,之筛选包含指定属性的View和指定类型的View。View获取到后,就保存下来,方便以后修改样式。

/**
 * View的属性集合
 */
class SkinAttribute {
    ...
    /**
     * 筛选View
     *
     * @param view
     * @param attrs
     */
    public void load(View view, AttributeSet attrs) {
        List<SkinPair> skinPairs = new ArrayList<>();
        for (int i = 0; i < attrs.getAttributeCount(); i++) {
            //获取属性名
            String attributeName = attrs.getAttributeName(i);
            //判断属性是否需要处理
            if (attributes.contains(attributeName)) {
                String attributeValue = attrs.getAttributeValue(i);
                //不处理写死的属性值
                if (attributeName.startsWith("#")) {
                    continue;
                }
                int resID;
                //以?开的的资源,定义在theme的attr属性中
                if (attributeValue.startsWith("?")) {
                    //主题中的属性ID
                    int attrID = Integer.parseInt(attributeValue.substring(1));
                    //实际的资源ID
                    resID = SkinThemeUtils.getResID(view.getContext(), new int[]{attrID})[0];
                } else {
                    resID = Integer.parseInt(attributeValue.substring(1));
                }

                //可被替换的属性
                if (resID != 0) {
                    SkinPair skinPair = new SkinPair(attributeName, resID);
                    skinPairs.add(skinPair);
                }
            }
        }

        //将view与之对应的可动态替换的属性放入集合
        if (!skinPairs.isEmpty()) {
            SkinView skinView = new SkinView(view, skinPairs);
            skinView.applySkin();
            skinViews.add(skinView);
        }
    }
    ...
}

2、获取样式

样式来自于皮肤包,所以先下载皮肤包。下载的皮肤包保存到SD卡中,通过AssetManager加载到内存中,得到皮肤包的Resources对象。

/**
 * 换肤管理器
 */
public class SkinManager extends Observable {
    /**
     * 加载皮肤包并更新view
     *
     * @param path 皮肤包路径
     */
    public void loadSkin(String path) {
       ...
                AssetManager assetManager = AssetManager.class.newInstance();
                //将皮肤包添加到资源管理器
                Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
                addAssetPath.setAccessible(true);
                addAssetPath.invoke(assetManager, path);

                Resources resources = application.getResources();
                //获取默认资源的横竖屏与语言参数
                Resources newResource = new Resources(assetManager, resources.getDisplayMetrics(), resources.getConfiguration());
                //获取皮肤包的包名
                PackageManager packageManager = application.getPackageManager();
                PackageInfo info = packageManager.getPackageArchiveInfo(path, PackageManager.GET_ACTIVITIES);
                String packageName = info.packageName;
                //加载皮肤包
                SkinResources.getInstance().applySkin(newResource, packageName);
                //保存当前使用的皮肤包
                SkinPreference.getInstance().setSkin(path);
    ...
    }
}

注意:皮肤包下载后,要保存路径到首选项中,方便下次直接调用。

获取到皮肤包的Resources对象后,就可以根据View的资源ID获取新样式。根据View的资源ID获取资源名,在根据资源名获取皮肤包中的同名资源,即为新的资源。然后根据皮肤包的资源ID,获取资源对象。

/**
 * 资源获取工具类
 */
class SkinResources {
    ...
    /**
     * 根据资源ID获取新皮肤的ID
     *
     * @param resID
     * @return
     */
    public int getIdentifier(int resID) {
        if (isDefaultSkin) {
            return resID;
        }
        //资源名称
        String resName = appResources.getResourceEntryName(resID);
        //资源类型
        String resType = appResources.getResourceTypeName(resID);
        //新皮肤的ID
        int id = newResources.getIdentifier(resName, resType, skinPackageName);
        return id;
    }

    /**
     * 返回新皮肤的Color
     *
     * @param resId
     * @return
     */
    public int getColor(int resId) {
        if (isDefaultSkin) {
            return appResources.getColor(resId);
        }
        int skinId = getIdentifier(resId);
        if (skinId == 0) {
            return appResources.getColor(resId);
        }
        return newResources.getColor(skinId);
    }
    ...
}

3、设置样式

这里使用观察者模式,当皮肤包下载后,就更新缓存View的样式。

/**
 * 布局加载器
 * 用来设置view的属性
 */
class SkinLayoutFactory implements LayoutInflater.Factory2, Observer {
    ...
    @Override
    public void update(Observable o, Object arg) {
        //换皮肤
        skinAttribute.applySkin();
    }
    ...
}

更新View的样式时,需要筛选属性。

/**
 * 用来处理view和对应的属性
 */
class SkinView {
   ...
    /**
     * 换皮肤
     */
    public void applySkin() {
        for (SkinPair skinPair : skinPairs) {
            Drawable left = null, top = null, right = null, bottom = null;
            switch (skinPair.attributeName) {
                case "background":
                    Object background = SkinResources.getInstance().getBackground(skinPair.resID);
                    //如果是Color
                    if (background instanceof Integer) {
                        view.setBackgroundColor((Integer) background);
                    } else {
                        //兼容低版本的写法
                        ViewCompat.setBackground(view, (Drawable) background);
                    }
                    break;
                case "src":
                    background = SkinResources.getInstance().getBackground(skinPair.resID);
                    //如果是Color
                    if (background instanceof Integer) {
                        ((ImageView) view).setImageDrawable(new ColorDrawable((Integer) background));
                    } else {
                        ((ImageView) view).setImageDrawable((Drawable) background);
                    }
                    break;

                case "textColor":
                    ((TextView) view).setTextColor(SkinResources.getInstance().getColorStateList(skinPair.resID));
                    break;
                case "drawableLeft":
                    left = SkinResources.getInstance().getDrawable(skinPair.resID);
                    break;
                case "drawableTop":
                    left = SkinResources.getInstance().getDrawable(skinPair.resID);
                    break;
                case "drawableRight":
                    left = SkinResources.getInstance().getDrawable(skinPair.resID);
                    break;
                case "drawableBottom":
                    left = SkinResources.getInstance().getDrawable(skinPair.resID);
                    break;
                default:
                    break;
            }

            if (left != null || right != null || top != null || bottom != null) {
                ((TextView) view).setCompoundDrawables(left, top, right, bottom);
            }
        }
    }
}

五、动态换肤扩展

1、Fragment的扩展

获取View是通过设置Factory2来实现的,所以只需要色画质Fragment的Factory2就可以了。但是Fragment会优先使用Activity的Factory2,所以不需要重新设置了。

public class Fragment implements ComponentCallbacks2, OnCreateContextMenuListener {
    ...
    /**
     * Returns the LayoutInflater used to inflate Views of this Fragment. The default
     * implementation will throw an exception if the Fragment is not attached.
     *
     * @return The LayoutInflater used to inflate Views of this Fragment.
     */
    public LayoutInflater onGetLayoutInflater(Bundle savedInstanceState) {
        if (mHost == null) {
            throw new IllegalStateException("onGetLayoutInflater() cannot be executed until the "
                    + "Fragment is attached to the FragmentManager.");
        }
        final LayoutInflater result = mHost.onGetLayoutInflater();
        if (mHost.onUseFragmentManagerInflaterFactory()) {
            getChildFragmentManager(); // Init if needed; use raw implementation below.
            result.setPrivateFactory(mChildFragmentManager.getLayoutInflaterFactory());
        }
        return result;
    }
    ...
}

2、状态栏和导航栏的扩展

状态栏和导航栏可以通过Activity直接获取,不需要通过Factory2获取。

获取状态栏颜色:先根据android.R.attr.statusBarColor属性获取皮肤包中的资源,获取不到,再根据android.support.v7.appcompat.R.attr.colorPrimaryDark属性获取。

获取导航栏颜色:根据android.R.attr.navigationBarColor属性获取皮肤包中的资源。

注意:在5.0以上版本才可以在Theme中配置状态栏和导航栏属性。

注意:android.R.attr.statusBarColor属性默认指向colorPrimary资源。

/**
 * 主图资源ID工具类
 */
public class SkinThemeUtils {
    ...
    /**
     * 修改状态栏和导航栏颜色
     *
     * @param activity
     */
    public static void updateStatusBar(Activity activity) {
        //5.0以上才能修改
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
            return;
        }
        int[] resIds = getResID(activity, STATUSBAR_COLOR_ATTRS);

        //状态栏的颜色如果没有使用statusBarColor,就会使用V7包的colorPrimaryDark
        if (resIds[0] == 0) {
            int statusBarColorId = getResID(activity, APPCOMPAT_COLOR_PRIMARY_DARK_ATTRS)[0];
            if (statusBarColorId != 0){
          activity.getWindow().setStatusBarColor(SkinResources.getInstance().getColor(statusBarColorId));
            }
        } else {
activity.getWindow().setStatusBarColor(SkinResources.getInstance().getColor(resIds[0]));
        }
        //修改底部虚拟按键的颜色
        if (resIds[1] != 0) {
activity.getWindow().setNavigationBarColor(SkinResources.getInstance().getColor(resIds[1]));
        }
    }
    ...
}

状态栏和导航栏的修改需要在Activity创建和更换皮肤包时调用。

3、字体的扩展

字体需要保存在资产目录中。

这里使用自定义的属性,来设置字体文件在资产目录中的路径。修改字体也就是修改字体文件的路径,再根据路径获取字体,从而实现换肤。

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <attr name="skinTypeface" format="string" />
</resources>

设置全局字体,在Theme中设置skinTypeface属性;设置控件的字体,就设置其skinTypeface属性。

获取字体时,先根据skinTypeface属性的ID值,获取资源ID,然后根据资源ID获取皮肤包中的字体。

/**
 * 资源获取工具类
 */
public class SkinResources {
    ...
    /**
     * 返回皮肤包的Typeface(字体)
     *
     * @param resId
     * @return
     */
    public Typeface getTypeface(int resId) {
        String skinTypefacePath = getString(resId);
        if (TextUtils.isEmpty(skinTypefacePath)) {
            return Typeface.DEFAULT;
        }
        try {
            Typeface typeface;
            if (isDefaultSkin) {
                typeface = Typeface.createFromAsset(appResources.getAssets(), skinTypefacePath);
                return typeface;
            }
            typeface = Typeface.createFromAsset(newResources.getAssets(), skinTypefacePath);
            return typeface;
        } catch (RuntimeException e) {
            e.printStackTrace();
        }
        return Typeface.DEFAULT;
    }
}

得到皮肤包的字体后,就修改筛选后的View的字体。

4、自定义控件的扩展

自定义控件由于属性不确定,所以通过接口来扩展。

自定义控件的获取也是在Factory2中获取,只是筛选时,要判断是否是SkinViewSupport类型。

/**
 * View的属性集合
 */
class SkinAttribute {
   /**
     * 筛选View
     *
     * @param view
     * @param attrs
     */
    public void load(View view, AttributeSet attrs) {
        ...
        //将view与之对应的可动态替换的属性放入集合
        //TextView都放入集合中,用于修改全局字体
        //SkinViewSupport接口的View都放入集合中
        if (!skinPairs.isEmpty() || view instanceof TextView || view instanceof SkinViewSupport) {
            SkinView skinView = new SkinView(view, skinPairs);
            skinView.applySkin(typeface);
            skinViews.add(skinView);
        }
    }
    ...
}

更换皮肤时,调用控件自己的applySkin(),实现自定义控件的扩展。

/**
 * 用来处理view和对应的属性
 */
class SkinView {
    /**
     * 换皮肤
     *
     * @param typeface
     */
    public void applySkin(Typeface typeface) {
        ...
        applySkinSupport();
        ...
    }

    /**
     * 修改自定义控件
     */
    private void applySkinSupport() {
        if (view instanceof SkinViewSupport) {
            ((SkinViewSupport) view).applySkin();
        }
    }
    ...
}

对于自定义的控件的支持性不够,需要实现SkinViewSupport接口,在换肤时,调用其applySkin(),从而修改样式。

public class CircleView extends View implements SkinViewSupport {
    ...
    @Override
    public void applySkin() {
        if (corcleColorResId != 0) {
            int color = SkinResources.getInstance().getColor(corcleColorResId);
            setCorcleColor(color);
        }
    }
}

最后

代码地址:https://gitee.com/yanhuo2008/Common/tree/master/ToolSkin

移动架构专题:https://www.jianshu.com/nb/25128604

喜欢请点赞,谢谢!

相关文章

  • 移动架构04-动态换肤框架

    移动架构04-动态换肤框架 一、前言 换肤就是修改app的样式(包括文字、颜色、背景等),通常用来提升用户体验。 ...

  • 动态换肤框架

    换肤模式 内置换肤 在APK包中存在多种资源(图片、颜色值等)用于换肤切换自由度低,APK文件大一般用于没有其他需...

  • 动态换肤一(前期预备知识)

      动态换肤框架是仿照网易云音乐来换肤的,换肤的方式就是通过解压 apk 文件从中获取到皮肤包的资源,然后替换我们...

  • Android动态换肤框架-实现换肤

    1、换肤流程 2、采集流程 3、Android资源查找流程 4、采集需要换肤的控件 换肤我们需要换所有可能需要换的...

  • Android动态换肤框架-换肤原理

    注:下文源码有删减,截图只体现主流程 1、换肤原理 换肤就是替换资源(文字、颜色、图片等),而换肤基本有两种模式:...

  • LSN10-动态化换肤框架

    LSN10-动态化换肤框架 fragment源码分析 androidx.fragment.app.Fragment...

  • Android-Skin-Loader源码解析

    源码 一:简介 Android-Skin-Loader是一个通过动态加载技术实现换肤的框架;解决换肤的两个核心问题...

  • 动态换肤框架1-基础换肤

    一、换肤的两种方式 内置换肤(静态):在Apk包中存在多种资源(图片、颜色值)用于换肤时候切换。缺点是自由度低,a...

  • iOS换肤功能的简单处理框架

    iOS换肤功能的简单处理框架 iOS换肤功能的简单处理框架

  • Android 动态换肤原理与实现

    概述 本文主要分享类似于酷狗音乐动态换肤效果的实现。 动态换肤的思路: 收集换肤控件以及对应的换肤属性 加载插件皮...

网友评论

    本文标题:移动架构04-动态换肤框架

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