Mobile Architecture 04-In-depth analysis of dynamic skinning framework

Mobile Architecture 04 - Dynamic Skinning Framework

I. Introduction

Skinning is to modify the style of the app (including text, color, background, etc.), usually to improve the user experience.

The skinning methods are divided into internal skinning and dynamic skinning.

Internal skinning is to switch when there are multiple resources (pictures, color values) in the Apk package for skinning. The disadvantage is that the degree of freedom is low and the apk file is large. Typically used for day/night mode apps with no other requirements.

Dynamic skinning is done by dynamically loading skin packs at runtime. The more typical ones are Gaode Map and NetEase Cloud Music.

The Gaode map is to replace the style by updating the configuration file. Suitable for simple skinning, such as: modifying colors.

NetEase Cloud Music replaces the style by replacing the complete skin package (APK file). It has the most powerful functions and occupies the most resources.

2. Dynamic skinning application

How to use this framework?

1. Basic application

The application is very simple, divided into two steps: initialization and setting the skin.

Initialization needs to be done in Application.

public class MyApplication extends android.app.Application {

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

Then set the skin in the Activity. Before setting the skin, you need to download the skin. The download step is omitted here.

/**
 * 一键换肤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);
    }
}

Note: A skin package is just an APK file. You can generate an APK file from a Module without code through the Build-Build APK(s) tool of Android Studio. The resource attribute name of the skin package must be consistent with the resource attribute name of the APP.

2. Status bar and navigation bar

In versions 5.0 and above, the properties of the status bar and navigation bar can be defined in the theme. After setting the properties, configure the resource of the same name in the skin package to realize the status bar skinning.

To improve compatibility, create styles.xml in the vaule-v21 directory:

<?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>

Then set the colorPrimaryDark resource in the skin package and it's OK.

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

3. Fonts

Set the global font, set the skinTypeface property in Theme, the value is the path of the font in assets:

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

To set a specific font, set the skinTypeface property in the layout file, the value is the path of the font in assets:

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

E.g:

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

4. Custom controls

The support for custom controls is not enough, you need to implement the SkinViewSupport interface, and when skinning, call its applySkin() to modify the style.

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

3. Principle of dynamic skinning

This article adopts the dynamic skinning method of NetEase Cloud Music.

The essence of skinning is to modify the style.

First, which Views need to be styled?

We can filter the View of the specified type and attribute and cache it when the Activity is created. When the skin package is specified, the styles of these Views can be filtered.

Then, change to what style?

A skin pack is an APK file, and its resources can be obtained after being loaded into memory. Obtaining the corresponding resource from the skin package through the resource ID of the current View is the style that needs to be modified.

Fourth, dynamic skinning implementation

The implementation of dynamic skinning is divided into three steps: acquiring View, acquiring styles, and applying styles.

1. Get View

The dynamic skinning framework is for the entire application, that is, to modify the style of all Activity. Monitor all Activity through Application.ActivityLifecycleCallbacks, and get View when Activity is created.

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 creates View through LayoutInflater, and LayoutInflater will use Factory2 to create View first. And this Factory2 can be specified, so using a custom Factory2 you can get the View and set the style.

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;
    }
    ...
}

Not all Views need to be skinned, to filter Views that contain specified properties and Views of specified types. After the View is obtained, it is saved to facilitate the modification of the style in the future.

/**
 * 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. Get the style

The styles come from the skin pack, so download the skin pack first. The downloaded skin package is saved to the SD card, loaded into the memory through AssetManager, and the Resources object of the skin package is obtained.

/**
 * 换肤管理器
 */
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);
    ...
    }
}

Note: After the skin package is downloaded, save the path to the preferences so that it can be called directly next time.

After obtaining the Resources object of the skin package, you can obtain the new style according to the resource ID of the View. Obtain the resource name according to the resource ID of the View, and obtain the resource with the same name in the skin package according to the resource name, which is a new resource. Then get the resource object according to the resource ID of the skin pack.

/**
 * 资源获取工具类
 */
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. Set the style

The observer mode is used here. When the skin package is downloaded, the style of the cached View is updated.

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

When updating the style of the View, the filter property is required.

/**
 * 用来处理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);
            }
        }
    }
}

Five, dynamic skinning extension

1. Fragment extension

Obtaining View is achieved by setting Factory2, so only Factory2 of Fragment with color quality is needed. But Fragment will use Factory2 of Activity first, so there is no need to reset it.

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. Extension of status bar and navigation bar

The status bar and navigation bar can be obtained directly through Activity, and do not need to be obtained through Factory2.

Obtaining the status bar color: first obtain the resources in the skin package according to the android.R.attr.statusBarColor attribute, if it cannot be obtained, then obtain it according to the android.support.v7.appcompat.R.attr.colorPrimaryDark attribute.

Get navigation bar color: Get the resources in the skin package according to the android.R.attr.navigationBarColor property.

Note: The status bar and navigation bar properties can be configured in Theme only in versions 5.0 and above.

Note: The android.R.attr.statusBarColor attribute points to the colorPrimary resource by default.

/**
 * 主图资源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]));
        }
    }
    ...
}

The modification of the status bar and navigation bar needs to be called when the Activity is created and the skin pack is changed.

3. Font expansion

Fonts need to be saved in the assets directory.

Custom properties are used here to set the path of the font file in the asset directory. Modifying the font is to modify the path of the font file, and then obtain the font according to the path, so as to achieve skinning.

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

To set the global font, set the skinTypeface property in Theme; to set the font of the control, set its skinTypeface property.

When obtaining the font, first obtain the resource ID according to the ID value of the skinTypeface attribute, and then obtain the font in the skin package according to the resource 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;
    }
}

After getting the font of the skin package, modify the font of the filtered View.

4. Extension of custom controls

Since the properties of custom controls are uncertain, they are extended through interfaces.

The acquisition of custom controls is also acquired in Factory2, but when screening, it is necessary to determine whether it is of the SkinViewSupport type.

/**
 * 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);
        }
    }
    ...
}

When changing the skin, call the control's own applySkin() to realize the extension of the custom control.

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

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

The support for custom controls is not enough, you need to implement the SkinViewSupport interface, and when skinning, call its applySkin() to modify the style.

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

The code has been uploaded to gitee, and students who like it can download it. By the way, give a like!

Previous: Mobile Architecture 03 - In-depth Analysis of Object-Oriented Database Framework

Next: Mobile Architecture 05-Componentization and Arouter Routing Framework

Guess you like

Origin http://43.154.161.224:23101/article/api/json?id=325526521&siteId=291194637