Android seamless skinning in-depth understanding and use

overall structure of ideas

Android skinning

Schemes and Wheels

  1. Internal resource loading scheme
    • By setTheme in BaseActivity
    • It is not easy to refresh in real time, you need to recreate the page
    • There are issues that need to be resolved which Vews need to be refreshed
  2. Custom View
    • MultipleTheme
    • Refresh resources immediately after custom View cooperates with setTheme.
    • Need to replace all views that need to be skinned
  3. Custom xml attributes, binding view in Java
    • Colorful
    • First by adding view in java code
    • Then setTheme sets the current page theme
    • Finally, the resource is modified by traversing the view through the context getTheme of the internal reference
  4. Dynamic resource loading scheme
    • Android-Skin-Loader
    • ThemeSkinning (a derivation of the above framework, the whole article is the framework studied)
    • Resource replacement: By packaging a resource apk separately, it is only used to access resources, and the resource name should correspond to itself
    • No need to care how many skins, downloadable, etc.
    • ready to adopt the program

The technical point of adopting the scheme

  1. Get the resources of the skin resource pack apk
  2. Customize the xml attribute to mark the view that needs to be skinned
  3. Get and corresponding layouts with skinning needs
  4. other
    • Extensions can add their own properties that support skinning
    • Change status bar color
    • change font

The implementation process of adopting the scheme

Implementation process

Load the skin apk to get the resources inside (in order to get the skin apk Resources object)

All code locations below, including solutions to deal with some special problems, and more!

https://github.com/xujiaji/ThemeSkinning

Through the full path of the skin apk, you can know its package name (you need to use the package name to get its resource id)

  • skinPkgPathis the full path of the apk, through mInfo.packageNamewhich you can get the package name
  • Code Location: SkinManager.java
    PackageManager mPm = context.getPackageManager();
    PackageInfo mInfo = mPm.getPackageArchiveInfo(skinPkgPath, PackageManager.GET_ACTIVITIES);
    skinPackageName = mInfo.packageName;

Adding a path through reflection can create an AssetManager object of the skin apk

  • skinPkgPathIt is the full path of the apk. The method of adding the path is a hidden method in the AssetManager that can be set through reflection.
  • At this point, it can also be assetManagerused to access the resources in the assets directory of the apk.
  • Think about if the replacement resources are placed in the assets directory, then we can do something here.
    AssetManager assetManager = AssetManager.class.newInstance();
    Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
    addAssetPath.invoke(assetManager, skinPkgPath);

Create a resource object for the skin apk

  • Get the Resources of the current app, mainly to create the Resources of the apk
    Resources superRes = context.getResources();
    Resources skinResource = new Resources(assetManager, superRes.getDisplayMetrics(), superRes.getConfiguration());

When you want to get the color by resource id

  1. Get the built-in color firstint originColor = ContextCompat.getColor(context, resId);
  2. If there is no external skin apk resource or just use the default resource, return the built-in color directly
  3. Get its name by context.getResources().getResourceEntryName(resId);getting the resource id
  4. By mResources.getIdentifier(resName, "color", skinPackageName)getting the resource id in the skin apk. (resName: is the resource name; skinPackegeName is the package name of the skin apk)
  5. If the resource id in the skin apk is not obtained (that is, equal to 0), return the original color, otherwise returnmResources.getColor(trueResId)

The id can be obtained by the name through the getIdentifiermethod, such as changing the second parameter to layout, mipmap, drawableor the resource id in the stringcorresponding layout目录, mipmap目录, drawable目录or by the resource name.string文件

    public int getColor(int resId) {
        int originColor = ContextCompat.getColor(context, resId);
        if (mResources == null || isDefaultSkin) {
            return originColor;
        }

        String resName = context.getResources().getResourceEntryName(resId);

        int trueResId = mResources.getIdentifier(resName, "color", skinPackageName);
        int trueColor;
        if (trueResId == 0) {
            trueColor = originColor;
        } else {
            trueColor = mResources.getColor(trueResId);
        }
        return trueColor;
    }

When you want to get the image by the resource id

  1. It is similar to the color obtained above
  2. It is only judged whether the picture is in the drawabledirectory or the directorymipmap
    public Drawable getDrawable(int resId) {
        Drawable originDrawable = ContextCompat.getDrawable(context, resId);
        if (mResources == null || isDefaultSkin) {
            return originDrawable;
        }
        String resName = context.getResources().getResourceEntryName(resId);
        int trueResId = mResources.getIdentifier(resName, "drawable", skinPackageName);
        Drawable trueDrawable;
        if (trueResId == 0) {
            trueResId = mResources.getIdentifier(resName, "mipmap", skinPackageName);
        }
        if (trueResId == 0) {
            trueDrawable = originDrawable;
        } else {
            if (android.os.Build.VERSION.SDK_INT < 22) {
                trueDrawable = mResources.getDrawable(trueResId);
            } else {
                trueDrawable = mResources.getDrawable(trueResId, null);
            }
        }
        return trueDrawable;
    }

Intercept all views

  • Implement LayoutInflater.Factory2the interface yourself to replace the system default

So how to replace it?

  • Just like this by calling super.onCreate before super.onCreate in the Activity method
  • Code location: SkinBaseActivity.java
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        mSkinInflaterFactory = new SkinInflaterFactory(this);//自定义的Factory
        LayoutInflaterCompat.setFactory2(getLayoutInflater(), mSkinInflaterFactory);
        super.onCreate(savedInstanceState);
    }

The Activity we use is generally AppCompatActivityset and initialized in the onCreate method inside, but the setFactory method can only be called once, resulting in some default initialization operations not being called. How to do this?

  • This is LayoutInflater.Factory2the class that implements the interface, see the onCreateViewmethod. delegate.createView(parent, name, context, attrs)The set of logic that calls the processing system before doing other operations .
  • attrs.getAttributeBooleanValueGets whether the current view is skinnable. The first parameter is the xml namespace, the second parameter is the attribute name, and the third parameter is the default value. This is equivalent toattrs.getAttributeBooleanValue("http://schemas.android.com/android/skin", "enable", false)
  • Code Location: SkinInflaterFactory.java
public class SkinInflaterFactory implements LayoutInflater.Factory2 {

    private AppCompatActivity mAppCompatActivity;

    public SkinInflaterFactory(AppCompatActivity appCompatActivity) {
        this.mAppCompatActivity = appCompatActivity;
    }
    @Override
    public View onCreateView(String s, Context context, AttributeSet attributeSet) {
        return null;
    }

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

        boolean isSkinEnable = attrs.getAttributeBooleanValue(SkinConfig.NAMESPACE, SkinConfig.ATTR_SKIN_ENABLE, false);//是否是可换肤的view
        AppCompatDelegate delegate = mAppCompatActivity.getDelegate();
        View view = delegate.createView(parent, name, context, attrs);//处理系统逻辑
        if (view instanceof TextView && SkinConfig.isCanChangeFont()) {
            TextViewRepository.add(mAppCompatActivity, (TextView) view);
        }

        if (isSkinEnable || SkinConfig.isGlobalSkinApply()) {
            if (view == null) {
                view = ViewProducer.createViewFromTag(context, name, attrs);
            }
            if (view == null) {
                return null;
            }
            parseSkinAttr(context, attrs, view);
        }
        return view;
    }
}

When the internal initialization operation is completed, if it is judged that the view has not been created, we need to create the view ourselves

  • See the previous step is ViewProducer.createViewFromTag(context, name, attrs)to create by
  • Then take a look at this class directly ViewProducer, please see the code comments for the principle function
  • In AppCompatViewInflater you can see the same code
  • Code location: ViewProducer.java
class ViewProducer {
    //该处定义的是view构造方法的参数,也就是View两个参数的构造方法:public View(Context context, AttributeSet attrs)
    private static final Object[] mConstructorArgs = new Object[2];
    //存放反射得到的构造器
    private static final Map<String, Constructor<? extends View>> sConstructorMap
            = new ArrayMap<>();
    //这是View两个参数的构造器所对应的两个参数
    private static final Class<?>[] sConstructorSignature = new Class[]{
            Context.class, AttributeSet.class};
    //如果是系统的View或ViewGroup在xml中并不是全路径的,通过反射来实例化是需要全路径的,这里列出来它们可能出现的位置
    private static final String[] sClassPrefixList = {
            "android.widget.",
            "android.view.",
            "android.webkit."
    };

    static View createViewFromTag(Context context, String name, AttributeSet attrs) {
        if (name.equals("view")) {//如果是view标签,则获取里面的class属性(该View的全名)
            name = attrs.getAttributeValue(null, "class");
        }

        try {
            //需要传入构造器的两个参数的值
            mConstructorArgs[0] = context;
            mConstructorArgs[1] = attrs;

            if (-1 == name.indexOf('.')) {//如果不包含小点,则是内部View
                for (int i = 0; i < sClassPrefixList.length; i++) {//由于不知道View具体在哪个路径,所以通过循环所有路径,直到能实例化或结束
                    final View view = createView(context, name, sClassPrefixList[i]);
                    if (view != null) {
                        return view;
                    }
                }
                return null;
            } else {//否则就是自定义View
                return createView(context, name, null);
            }
        } catch (Exception e) {
            //如果抛出异常,则返回null,让LayoutInflater自己去实例化
            return null;
        } finally {
            // 清空当前数据,避免和下次数据混在一起
            mConstructorArgs[0] = null;
            mConstructorArgs[1] = null;
        }
    }

    private static View createView(Context context, String name, String prefix)
            throws ClassNotFoundException, InflateException {
        //先从缓存中获取当前类的构造器
        Constructor<? extends View> constructor = sConstructorMap.get(name);
        try {
            if (constructor == null) {
                // 如果缓存中没有创建过,则尝试去创建这个构造器。通过类加载器加载这个类,如果是系统内部View由于不是全路径的,则前面加上
                Class<? extends View> clazz = context.getClassLoader().loadClass(
                        prefix != null ? (prefix + name) : name).asSubclass(View.class);
                //获取构造器
                constructor = clazz.getConstructor(sConstructorSignature);
                //将构造器放入缓存
                sConstructorMap.put(name, constructor);
            }
            //设置为无障碍(设置后即使是私有方法和成员变量都可访问和修改,除了final修饰的)
            constructor.setAccessible(true);
            //实例化
            return constructor.newInstance(mConstructorArgs);
        } catch (Exception e) {
            // We do not want to catch these, lets return null and let the actual LayoutInflater
            // try
            return null;
        }
    }
}
  • Of course, there is another way to create it, which is to use the set inside LayoutInflater directly
  • Will be view = ViewProducer.createViewFromTag(context, name, attrs);deleted and replaced with the following code:
  • Code Location: SkinInflaterFactory.java
    LayoutInflater inflater = mAppCompatActivity.getLayoutInflater();
    if (-1 == name.indexOf('.'))//如果为系统内部的View则,通过循环这几个地方来实例化View,道理跟上面ViewProducer里面一样
    {
        for (String prefix : sClassPrefixList)
        {
            try
            {
                view = inflater.createView(name, prefix, attrs);
            } catch (ClassNotFoundException e)
            {
                e.printStackTrace();
            }
            if (view != null) break;
        }
    } else
    {
        try
        {
            view = inflater.createView(name, null, attrs);
        } catch (ClassNotFoundException e)
        {
            e.printStackTrace();
        }
    }
  • sClassPrefixListDefinition
    private static final String[] sClassPrefixList = {
            "android.widget.",
            "android.view.",
            "android.webkit."
    };

Finally, the final interception gets the part of the View that needs to be skinned, which is the last method called by the above SkinInflaterFactoryclass.onCreateViewparseSkinAttr

  • Define a member of the class to save all the Views that need to be skinned. The logic in SkinItem is to define the method of setting skinning. Such as: View's setBackgroundColor or setColor and other settings for skinning rely on it.
private Map<View, SkinItem> mSkinItemMap = new HashMap<>();
  • SkinAttr: The xml attribute that needs to be skinned. For how to define it, please refer to the official document: https://github.com/burgessjp/ThemeSkinning
    private void parseSkinAttr(Context context, AttributeSet attrs, View view) {
        //保存需要换肤处理的xml属性
        List<SkinAttr> viewAttrs = new ArrayList<>();
        //变量该view的所有属性
        for (int i = 0; i < attrs.getAttributeCount(); i++) {
            String attrName = attrs.getAttributeName(i);//获取属性名
            String attrValue = attrs.getAttributeValue(i);//获取属性值
            //如果属性是style,例如xml中设置:style="@style/test_style"
            if ("style".equals(attrName)) {
                //可换肤的属性
                int[] skinAttrs = new int[]{android.R.attr.textColor, android.R.attr.background};
                //经常在自定义View时,构造方法中获取属性值的时候使用到。
                //这里通过传入skinAttrs,TypeArray中将会包含这两个属性和值,如果style里没有那就没有 - -
                TypedArray a = context.getTheme().obtainStyledAttributes(attrs, skinAttrs, 0, 0);
                //获取属性对应资源的id,第一个参数这里对应下标的就是上面skinAttrs数组里定义的下标,第二个参数是没有获取到的默认值
                int textColorId = a.getResourceId(0, -1);
                int backgroundId = a.getResourceId(1, -1);
                if (textColorId != -1) {//如果有颜色属性
                    //<style name="test_style">
                        //<item name="android:textColor">@color/colorAccent</item>
                        //<item name="android:background">@color/colorPrimary</item>
                    //</style>
                    //以上边的参照来看
                    //entryName就是colorAccent
                    String entryName = context.getResources().getResourceEntryName(textColorId);
                    //typeName就是color
                    String typeName = context.getResources().getResourceTypeName(textColorId);
                    //创建一换肤属性实力类来保存这些信息
                    SkinAttr skinAttr = AttrFactory.get("textColor", textColorId, entryName, typeName);
                    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);
                    if (skinAttr != null) {
                        viewAttrs.add(skinAttr);
                    }

                }
                a.recycle();
                continue;
            }
            //判断是否是支持的属性,并且值是引用的,如:@color/red
            if (AttrFactory.isSupportedAttr(attrName) && attrValue.startsWith("@")) {
                try {
                    //去掉属性值前面的“@”则为id
                    int id = Integer.parseInt(attrValue.substring(1));
                    if (id == 0) {
                        continue;
                    }
                    //资源名字,如:text_color_selector
                    String entryName = context.getResources().getResourceEntryName(id);
                    //资源类型,如: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());
                }
            }
        }
        //是否有需要换肤的属性?
        if (!SkinListUtils.isEmpty(viewAttrs)) {
            SkinItem skinItem = new SkinItem();
            skinItem.view = view;
            skinItem.attrs = viewAttrs;
            mSkinItemMap.put(skinItem.view, skinItem);
            //是否换肤
            if (SkinManager.getInstance().isExternalSkin() ||
                    SkinManager.getInstance().isNightMode()) {//如果当前皮肤来自于外部或者是处于夜间模式
                skinItem.apply();//应用于这个view
            }
        }
    }

Precautions and questions about adopting the program

  1. It is possible that the system will change the relevant methods, but the benefits outweigh the disadvantages
  2. Plug-in is also external apk to load, how to do it?
    • take time away from research
  3. In which directory are the skins downloaded from the Internet? How can I tell if the skin has been downloaded?
    • The cache directory of the skin can be obtained by SkinFileUtilscalling the method of the tool classgetSkinDir
    • When downloading, you can download directly to this directory
    • If there is a skin, you can judge whether there is this file in the folder
  4. How to preview directly without packaging?
    • If you want to be able to preview the effect in advance before packaging, instead of having to type an apk package every time you want to see the effect
    • First of all, everyone should know the concept of sub-channel. By sub-channel packaging, because we can also divide the resources into different channels, and run different channels, the resources obtained are different.
    • Then, in: 项目目录\app\src, create a directory with the same name as the channel. For example, there is a redchannel.
      channel definition
      red channel png
    • Finally, we choose the compilation channel as red, and then run it directly to see the effect. If you can directly copy the res to the skin project package, that's fine.
      Select compilation channel
  5. The properties corresponding to skinning need to be properties that View provides the set method!
    • Cannot set value in java code if not provided
    • If it is a custom View then add the corresponding method
    • If it is a system or class library View, eh(⊙o⊙)…
  6. The skinnable attribute value needs to be a data reference starting with @, such as: @color/red
    • The reason is because a fixed value is generally unlikely to be an attribute that needs to be skinned. There is such a sentence in SkinInfaterFactorythe method parseSkinAttrto filter the attribute value without @:
      Filter property values ​​without @
    • But at this time, there is a custom View that does not follow the usual way. Its value is that the image name has no type and no reference, and the context.getResources().getIdentifier(name, "mipmap", context.getPackageName())image resources are obtained through java code ( refer to the library of this wonderful way ). But since this attribute is an attribute that needs to be skinned and replaced, there is no way to write a judgment for these two attributes in SkinInfaterFactorythe method.parseSkinAttr
      Judge these two properties individually
      refer to this code

Other references

  1. Android theme skinning seamless switching (the main reference object, also uses his modified Android-Skin-Loaderframework ThemeSkinning)
  2. Summary of Android skinning technology
  3. Research on Dynamic Loading Mechanism of Android apk

Involvement and its extension

  1. Plug-in development, since you can get resources in this way, you can also get class files
  2. By intercepting the view, a control can be replaced as a whole. For example, AppCompatActivity secretly replaces TextView with AppCompatTextView and so on.

Some other helpful information:

The corresponding code snippets above have corresponding paths!

All the code of this article, test project location: https://github.com/xujiaji/ThemeSkinning

The homepage bottom navigation test and modification location in the test project: https://github.com/xujiaji/FlycoTabLayout

The following Gif image is the rendering of the test project running:

Guess you like

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