Android Hook技术实现一键换肤

产品要求app里面的图表要实现白天黑夜模式的切换,以满足不同光线下都能保证足够的图表清晰度。怎么办?可能解决的办法很多,可以给图表view增加一个toggle方法,参数String,day/night,然后切换之后postInvalidate刷新重绘。OK,可行,但是这种方式切换白天黑夜,只是单个View中有效,那么如果哪天产品又要另一个View换肤,难道我要一个一个去写toggle么?未免太low了。

什么是一键换肤

所谓"一键",就是通过"一个"接口的调用,就能实现全app范围内的所有资源文件的替换。包括文本,颜色,图片等。

一些换肤实现方式的对比

  • 方案1:自定义View中,要换肤,那如同引言中所述,toggle方法,invalidate重绘。

弊端:换肤范围仅限于这个View

  • 方案2:给静态变量赋值,然后重启Activity

如果一个Activity内用静态变量定义了两种色系,那么确实是可以通过关闭Activity,再启动的方式,实现貌似换肤的效果(其实是重新启动了Activity)

弊端:太low,而且很浪费资源

也许还有其他方案吧,View重绘,重启Activity,都能实现,但是仍然不是最优雅的方案,那么,有没有一种方案,能够实现全app内的换肤效果,又不会像重启 Activity 这样浪费资源呢?

界面上哪些元素是可以换肤的

答案其实就一句话:项目代码里面res目录下的所有东西,几乎都可以被替换

具体而言就是如下这些

  • 动画
  • 背景图片
  • 字体
  • 字体颜色
  • 字体大小
  • 音频
  • 视频

利用HOOK技术实现优雅的"一键换肤"

hook,钩子,安卓中的hook技术,其实是一个抽象概念:对系统源码的代码逻辑进行"劫持",插入自己的逻辑,然后放行。注意:hook可能频繁使用java反射机制

一键换肤"中的hook思路

(1)"劫持"系统创建View的过程,我们自己来创建View

系统原本自己存在创建View的逻辑,我们要了解这部分代码,以便为我所用

(2)收集我们需要换肤的View(用自定义view属性来标记一个view是否支持一键换肤),保存到变量中

劫持了系统创建view的逻辑之后,我们要把支持换肤的这些view保存起来

(3)加载外部资源包,调用接口进行换肤

外部资源包,是.apk后缀的一个文件,是通过gradle打包形成的。里面包含需要换肤的资源文件,但是必须保证,要换的资源文件,和原工程里面的文件名完全相同

相关android源码一览

(1)Activity 的 setContentView(R.layout.XXX) 到底在做什么?

回顾我们写app的习惯,创建Activity,写xxx.xml,在Activity里面setContentView(R.layout.xxx) 我们写的是xml,最终呈现出来的是一个一个的界面上的UI控件,那么setContentView到底做了什么事,使得XML里面的内容,变成了UI控件呢?

源码索引:

@Override
public void setContentView(@LayoutRes int layoutResID) {
    getDelegate().setContentView(layoutResID);
}

OK,这里暴露出了两个方法,getDelegate()setContentView()。先看getDelegate

@NonNull
public AppCompatDelegate getDelegate() {
    if (mDelegate == null) {
        mDelegate = AppCompatDelegate.create(this, this);
    }
    return mDelegate;
}

返回一个AppCompatDelegate对象,跟踪到AppCompatDelegate内部,阅读源码,可以得出一个结论:AppCompatDelegate是替Activity生成View对象的委托类,它提供了一系列setContentView方法,在Activity中加入UI控件。

那它的AppCompatDelegate的setContentView方法又做了什么?,找到setContentView的具体过程

@Override
public void setContentView(int resId) {
    ensureSubDecor();
    ViewGroup contentParent = mSubDecor.findViewById(android.R.id.content);
    contentParent.removeAllViews();
    LayoutInflater.from(mContext).inflate(resId, contentParent);
    mAppCompatWindowCallback.getWrapped().onContentChanged();
}

(2)LayoutInflater这个类是怎么把layout.xml<TextView>变成TextView对象的?

我们知道,我们传入的是int,是xxx.xml这个布局文件,在R文件里面的对应int值。LayoutInflater拿到这个int之后,又干了什么事呢?
一路索引进去:会发现这个方法:

public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
    synchronized (mConstructorArgs) {
        Trace.traceBegin(Trace.TRACE_TAG_VIEW, "inflate");

        final Context inflaterContext = mContext;
        final AttributeSet attrs = Xml.asAttributeSet(parser);
        Context lastContext = (Context) mConstructorArgs[0];
        mConstructorArgs[0] = inflaterContext;
        View result = root;

        try {
            advanceToRootNode(parser);
            final String name = parser.getName();

            if (DEBUG) {
                System.out.println("**************************");
                System.out.println("Creating root view: "
                        + name);
                System.out.println("**************************");
            }

            if (TAG_MERGE.equals(name)) {
                if (root == null || !attachToRoot) {
                    throw new InflateException("<merge /> can be used only with a valid "
                            + "ViewGroup root and attachToRoot=true");
                }

                rInflate(parser, root, inflaterContext, attrs, false);
            } else {
                // Temp is the root view that was found in the xml
                final View temp = createViewFromTag(root, name, inflaterContext, attrs);

                ViewGroup.LayoutParams params = null;

                if (root != null) {
                    if (DEBUG) {
                        System.out.println("Creating params from root: " +
                                root);
                    }
                    // Create layout params that match root, if supplied
                    params = root.generateLayoutParams(attrs);
                    if (!attachToRoot) {
                        // Set the layout params for temp if we are not
                        // attaching. (If we are, we use addView, below)
                        temp.setLayoutParams(params);
                    }
                }

                if (DEBUG) {
                    System.out.println("-----> start inflating children");
                }

                // Inflate all children under temp against its context.
                rInflateChildren(parser, temp, attrs, true);

                if (DEBUG) {
                    System.out.println("-----> done inflating children");
                }

                // We are supposed to attach all the views we found (int temp)
                // to root. Do that now.
                if (root != null && attachToRoot) {
                    root.addView(temp, params);
                }

                // Decide whether to return the root that was passed in or the
                // top view found in xml.
                if (root == null || !attachToRoot) {
                    result = temp;
                }
            }

        } catch (XmlPullParserException e) {
            final InflateException ie = new InflateException(e.getMessage(), e);
            ie.setStackTrace(EMPTY_STACK_TRACE);
            throw ie;
        } catch (Exception e) {
            final InflateException ie = new InflateException(
                    getParserStateDescription(inflaterContext, attrs)
                    + ": " + e.getMessage(), e);
            ie.setStackTrace(EMPTY_STACK_TRACE);
            throw ie;
        } finally {
            // Don't retain static reference on context.
            mConstructorArgs[0] = lastContext;
            mConstructorArgs[1] = null;

            Trace.traceEnd(Trace.TRACE_TAG_VIEW);
        }

        return result;
    }
}

发现一个关键方法:createViewFromTag,tag是指的什么?其实就是xml里面的标签头<TextView ....>

@UnsupportedAppUsage
View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
        boolean ignoreThemeAttr) {
    if (name.equals("view")) {
        name = attrs.getAttributeValue(null, "class");
    }

    // Apply a theme wrapper, if allowed and one is specified.
    if (!ignoreThemeAttr) {
        final TypedArray ta = context.obtainStyledAttributes(attrs, ATTRS_THEME);
        final int themeResId = ta.getResourceId(0, 0);
        if (themeResId != 0) {
            context = new ContextThemeWrapper(context, themeResId);
        }
        ta.recycle();
    }

    try {
        View view = tryCreateView(parent, name, context, attrs);

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

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

    } catch (ClassNotFoundException e) {
        final InflateException ie = new InflateException(
                getParserStateDescription(context, attrs)
                + ": Error inflating class " + name, e);
        ie.setStackTrace(EMPTY_STACK_TRACE);
        throw ie;

    } catch (Exception e) {
        final InflateException ie = new InflateException(
                getParserStateDescription(context, attrs)
                + ": Error inflating class " + name, e);
        ie.setStackTrace(EMPTY_STACK_TRACE);
        throw ie;
    }
}

这个方法有4个参数,意义分别是:

  • View parent 父组件
  • String name xml标签名
  • Context context 上下文
  • AttributeSet attrs view属性
  • boolean ignoreThemeAttr 是否忽略theme属性

进入tryCreateView,发现一段关键代码:

@UnsupportedAppUsage(trackingBug = 122360734)
@Nullable
public final View tryCreateView(@Nullable View parent, @NonNull String name,
    @NonNull Context context,
    @NonNull AttributeSet attrs) {
    if (name.equals(TAG_1995)) {
        // Let's party like it's 1995!
        return new BlinkLayout(context, attrs);
    }

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

    return view;
}

实际上,可能有人要问了,你怎么知道这边是走的哪一个if分支呢?
方法:新创建一个Project,跟踪MainActivity onCreate里面setContentView()一路找到这段代码debug,就会发现系统在默认情况下就会走Factory2onCreateView(),那么这个mFactory2对象是哪来的?什么时候设置进去的

public abstract class LayoutInflater {
    public void setFactory2(Factory2 factory) {
        if (mFactorySet) {
            throw new IllegalStateException("A factory has already been set on this LayoutInflater");
        }
        if (factory == null) {
            throw new NullPointerException("Given factory can not be null");
        }
        mFactorySet = true;
        if (mFactory == null) {
            mFactory = mFactory2 = factory;
        } else {
            mFactory = mFactory2 = new FactoryMerger(factory, factory, mFactory, mFactory2);
        }
    }
class AppCompatDelegateImpl extends AppCompatDelegate
        implements MenuBuilder.Callback, LayoutInflater.Factory2 {

    @Override
    public void installViewFactory() {
        LayoutInflater layoutInflater = LayoutInflater.from(mContext);
        if (layoutInflater.getFactory() == null) {
            LayoutInflaterCompat.setFactory2(layoutInflater, this);
        } else {
            if (!(layoutInflater.getFactory2() instanceof AppCompatDelegateImpl)) {
                Log.i(TAG, "The Activity's LayoutInflater already has a Factory installed"
                        + " so we can not install AppCompat's");
            }
        }
    }

getDelegate()得到的对象,和LayoutInflater里面mFactory2其实是同一个对象

那么继续跟踪,一直到:AppCompatViewInflater

public abstract class LayoutInflater {

    public interface Factory2 extends Factory {
        /**
         * Version of {@link #onCreateView(String, Context, AttributeSet)}
         * that also supplies the parent that the view created view will be
         * placed in.
         *
         * @param parent The parent that the created view will be placed
         * in; <em>note that this may be null</em>.
         * @param name Tag name to be inflated.
         * @param context The context the view is being created in.
         * @param attrs Inflation attributes as specified in XML file.
         *
         * @return View Newly created view. Return null for the default
         *         behavior.
         */
        @Nullable
        View onCreateView(@Nullable View parent, @NonNull String name,
                @NonNull Context context, @NonNull AttributeSet attrs);
    }
class AppCompatDelegateImpl extends AppCompatDelegate
        implements MenuBuilder.Callback, LayoutInflater.Factory2 {

    /**
     * From {@link LayoutInflater.Factory2}.
     */
    @Override
    public View onCreateView(String name, Context context, AttributeSet attrs) {
        return onCreateView(null, name, context, attrs);
    }

    @Override
    public View createView(View parent, final String name, @NonNull Context context,
            @NonNull AttributeSet attrs) {
        if (mAppCompatViewInflater == null) {
            TypedArray a = mContext.obtainStyledAttributes(R.styleable.AppCompatTheme);
            String viewInflaterClassName =
                    a.getString(R.styleable.AppCompatTheme_viewInflaterClass);
            if ((viewInflaterClassName == null)
                    || AppCompatViewInflater.class.getName().equals(viewInflaterClassName)) {
                // Either default class name or set explicitly to null. In both cases
                // create the base inflater (no reflection)
                mAppCompatViewInflater = new AppCompatViewInflater();
            } else {
                try {
                    Class<?> viewInflaterClass = Class.forName(viewInflaterClassName);
                    mAppCompatViewInflater =
                            (AppCompatViewInflater) viewInflaterClass.getDeclaredConstructor()
                                    .newInstance();
                } catch (Throwable t) {
                    Log.i(TAG, "Failed to instantiate custom view inflater "
                            + viewInflaterClassName + ". Falling back to default.", t);
                    mAppCompatViewInflater = new AppCompatViewInflater();
                }
            }
        }

        boolean inheritContext = false;
        if (IS_PRE_LOLLIPOP) {
            inheritContext = (attrs instanceof XmlPullParser)
                    // If we have a XmlPullParser, we can detect where we are in the layout
                    ? ((XmlPullParser) attrs).getDepth() > 1
                    // Otherwise we have to use the old heuristic
                    : shouldInheritContext((ViewParent) parent);
        }

        return mAppCompatViewInflater.createView(parent, name, context, attrs, inheritContext,
                IS_PRE_LOLLIPOP, /* Only read android:theme pre-L (L+ handles this anyway) */
                true, /* Read read app:theme as a fallback at all times for legacy reasons */
                VectorEnabledTintResources.shouldBeUsed() /* Only tint wrap the context if enabled */
        );
    }
public class AppCompatViewInflater {

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

        // We can emulate Lollipop's android:theme attribute propagating down the view hierarchy
        // by using the parent's context
        if (inheritContext && parent != null) {
            context = parent.getContext();
        }
        if (readAndroidTheme || readAppTheme) {
            // We then apply the theme on the context, if specified
            context = themifyContext(context, attrs, readAndroidTheme, readAppTheme);
        }
        if (wrapContext) {
            context = TintContextWrapper.wrap(context);
        }

        View view = null;

        // We need to 'inject' our tint aware Views in place of the standard framework versions
        switch (name) {
            case "TextView":
                view = createTextView(context, attrs);
                verifyNotNull(view, name);
                break;
            case "ImageView":
                view = createImageView(context, attrs);
                verifyNotNull(view, name);
                break;
            case "Button":
                view = createButton(context, attrs);
                verifyNotNull(view, name);
                break;
            case "EditText":
                view = createEditText(context, attrs);
                verifyNotNull(view, name);
                break;
            case "Spinner":
                view = createSpinner(context, attrs);
                verifyNotNull(view, name);
                break;
            case "ImageButton":
                view = createImageButton(context, attrs);
                verifyNotNull(view, name);
                break;
            case "CheckBox":
                view = createCheckBox(context, attrs);
                verifyNotNull(view, name);
                break;
            case "RadioButton":
                view = createRadioButton(context, attrs);
                verifyNotNull(view, name);
                break;
            case "CheckedTextView":
                view = createCheckedTextView(context, attrs);
                verifyNotNull(view, name);
                break;
            case "AutoCompleteTextView":
                view = createAutoCompleteTextView(context, attrs);
                verifyNotNull(view, name);
                break;
            case "MultiAutoCompleteTextView":
                view = createMultiAutoCompleteTextView(context, attrs);
                verifyNotNull(view, name);
                break;
            case "RatingBar":
                view = createRatingBar(context, attrs);
                verifyNotNull(view, name);
                break;
            case "SeekBar":
                view = createSeekBar(context, attrs);
                verifyNotNull(view, name);
                break;
            case "ToggleButton":
                view = createToggleButton(context, attrs);
                verifyNotNull(view, name);
                break;
            default:
                // The fallback that allows extending class to take over view inflation
                // for other tags. Note that we don't check that the result is not-null.
                // That allows the custom inflater path to fall back on the default one
                // later in this method.
                view = createView(context, name, attrs);
        }

        if (view == null && originalContext != context) {
            // If the original context does not equal our themed context, then we need to manually
            // inflate it using the name so that android:theme takes effect.
            view = createViewFromTag(context, name, attrs);
        }

        if (view != null) {
            // If we have created a view, check its android:onClick
            checkOnClickListener(view, attrs);
        }

        return view;
    }

这边利用了大量的switch case来进行系统控件的创建,例如:TextView

@NonNull
protected AppCompatTextView createTextView(Context context, AttributeSet attrs) {
    return new AppCompatTextView(context, attrs);
}

都是new 出来一个具有兼容特性的TextView,返回出去
但是,使用过switch 的人都知道,这种case形式的分支,无法涵盖所有的类型怎么办呢?这里switch之后,view仍然可能是null
所以,switch之后,谷歌大佬加了一个if,但是很诡异,这段代码并未进入if,因为originalContext != context并不满足....具体原因暂时也没查出来

if (view == null && originalContext != context) {
    // If the original context does not equal our themed context, then we need to manually
    // inflate it using the name so that android:theme takes effect.
    view = createViewFromTag(context, name, attrs);
}

然而,这里的补救措施没有执行,那自然有地方有另外的补救措施。回到之前的LayoutInflater的下面这段代码:

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;
}
View view = tryCreateView(parent, name, context, attrs);

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

return view;

这里的两个方法onCreateView(parent, name, attrs)createView(name, null, attrs)都最终索引到:

public final View createView(@NonNull Context viewContext, @NonNull String name,
        @Nullable String prefix, @Nullable AttributeSet attrs)
        throws ClassNotFoundException, InflateException {
    Objects.requireNonNull(viewContext);
    Objects.requireNonNull(name);
    Constructor<? extends View> constructor = sConstructorMap.get(name);
    if (constructor != null && !verifyClassLoader(constructor)) {
        constructor = null;
        sConstructorMap.remove(name);
    }
    Class<? extends View> clazz = null;

    try {
        Trace.traceBegin(Trace.TRACE_TAG_VIEW, name);

        if (constructor == null) {
            // Class not found in the cache, see if it's real, and try to add it
            clazz = Class.forName(prefix != null ? (prefix + name) : name, false,
                    mContext.getClassLoader()).asSubclass(View.class);

            if (mFilter != null && clazz != null) {
                boolean allowed = mFilter.onLoadClass(clazz);
                if (!allowed) {
                    failNotAllowed(name, prefix, viewContext, attrs);
                }
            }
            constructor = clazz.getConstructor(mConstructorSignature);
            constructor.setAccessible(true);
            sConstructorMap.put(name, constructor);
        } else {
            // If we have a filter, apply it to cached constructor
            if (mFilter != null) {
                // Have we seen this name before?
                Boolean allowedState = mFilterMap.get(name);
                if (allowedState == null) {
                    // New class -- remember whether it is allowed
                    clazz = Class.forName(prefix != null ? (prefix + name) : name, false,
                            mContext.getClassLoader()).asSubclass(View.class);

                    boolean allowed = clazz != null && mFilter.onLoadClass(clazz);
                    mFilterMap.put(name, allowed);
                    if (!allowed) {
                        failNotAllowed(name, prefix, viewContext, attrs);
                    }
                } else if (allowedState.equals(Boolean.FALSE)) {
                    failNotAllowed(name, prefix, viewContext, attrs);
                }
            }
        }

        Object lastContext = mConstructorArgs[0];
        mConstructorArgs[0] = viewContext;
        Object[] args = mConstructorArgs;
        args[1] = attrs;

        try {
            final View view = constructor.newInstance(args);
            if (view instanceof ViewStub) {
                // Use the same context when inflating ViewStub later.
                final ViewStub viewStub = (ViewStub) view;
                viewStub.setLayoutInflater(cloneInContext((Context) args[0]));
            }
            return view;
        } finally {
            mConstructorArgs[0] = lastContext;
        }
    } catch (NoSuchMethodException e) {
        final InflateException ie = new InflateException(
                getParserStateDescription(viewContext, attrs)
                + ": Error inflating class " + (prefix != null ? (prefix + name) : name), e);
        ie.setStackTrace(EMPTY_STACK_TRACE);
        throw ie;

    } catch (ClassCastException e) {
        // If loaded class is not a View subclass
        final InflateException ie = new InflateException(
                getParserStateDescription(viewContext, attrs)
                + ": Class is not a View " + (prefix != null ? (prefix + name) : name), e);
        ie.setStackTrace(EMPTY_STACK_TRACE);
        throw ie;
    } catch (ClassNotFoundException e) {
        // If loadClass fails, we should propagate the exception.
        throw e;
    } catch (Exception e) {
        final InflateException ie = new InflateException(
                getParserStateDescription(viewContext, attrs) + ": Error inflating class "
                        + (clazz == null ? "<unknown>" : clazz.getName()), e);
        ie.setStackTrace(EMPTY_STACK_TRACE);
        throw ie;
    } finally {
        Trace.traceEnd(Trace.TRACE_TAG_VIEW);
    }
}

这么一大段好像有点让人害怕。其实真正需要关注的,就是反射的代码,最后的newInstance()

(3)APP中资源文件大管家Resources/AssetManager是怎么工作的

从我们的终极目的出发:我们要做的是“换肤”,如果我们拿到了要换肤的View,可以对他们进行setXXX属性来改变UI,那么属性值从哪里来?
界面元素丰富多彩,但是这些View,都是用资源文件来进行"装扮"出来的,资源文件大致可以分为:图片、文字、颜色、声音视频、字体等。如果我们控制了资源文件,那么是不是有能力对界面元素进行set某某属性来进行“再装扮”呢?

当然,这是可行的。因为,我们平时拿到一个TextView,就能对它进行setTextColor,这种操作,在View还存活的时候,都可以进行操作,并且这种操作,并不会造成Activity的重启。

这些资源文件,有一个统一的大管家。可能有人说是R.java文件,它里面统筹了所有的资源文件int值。没错,但是这个R文件是如何产生作用的呢?答案:Resources

全APP一键换肤实现

项目工程结构

  • app module: applicationId "cn.appblog.skindemo"
  • blue_skin_plugin module: apply plugin: 'com.android.application', applicationId "cn.appblog.skindemo.skin.blue_skin_plugin"
  • orange_skin_plugin module: apply plugin: 'com.android.application', applicationId "cn.appblog.skindemo.skin.orange_skin_plugin"

皮肤包blue_skin_plugin moduleorange_skin_plugin module里面,只提供需要换肤的资源即可,不需要换肤的资源(如layout、attr)以及src目录下的Java源码不要放在这里,以免无端增大皮肤包的体积

注意:blue_skin_plugin moduleorange_skin_plugin module的资源文件(如color、drawable、mipmap、styles)应与app module保持一致(文件或资源Key一致),以确保其生成的id相同

皮肤包blue_skin_plugin moduleorange_skin_plugin module的gradle sdk版本最好和app module的保持完全一致,否则无法保证不会出现奇葩问题

使用皮肤包blue_skin_plugin moduleorange_skin_plugin module打包生成的apk文件,常规来说,是通过网络下载并加载,为调试方便放在手机SD卡中,然后由app module内的代码去加载

皮肤资源配置

确保资源文件(color、drawable、mipmap、styles)与app module保持一致(文件或资源Key一致)

要注意:打两个皮肤包运行demo,打包之前,一定要记得替换drawable图片资源为同名文件

app module

colors.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="colorPrimary">#008577</color>
    <color name="colorPrimaryDark">#00574B</color>
    <color name="colorAccent">#00574B</color>
</resources>

attrs.xml

<?xml version="1.0" encoding="utf-8"?>
<resources><!--TODO: 关键技术点2 通过自定义属性来标识哪些view支持换肤-->
    <declare-styleable name="Skinable">
        <!--TODO: isSupport=true标识当前控件支持换肤-->
        <attr name="isSupport" format="boolean" />
    </declare-styleable>
</resources>

blue_skin_plugin module

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="colorPrimary">#2CB6F3</color>
    <color name="colorPrimaryDark">#00A0E9</color>
    <color name="colorAccent">#00A0E9</color>
</resources>

orange_skin_plugin module

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="colorPrimary">#EA6f5A</color>
    <color name="colorPrimaryDark">#EC7259</color>
    <color name="colorAccent">#EC7259</color>
</resources>

关键类 SkinFactory

SkinFactory类,继承LayoutInflater.Factory2,它的实例,会负责创建View,收集支持换肤的View

package cn.appblog.skindemo.skin;

import android.content.Context;
import android.content.res.TypedArray;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.TextView;

import androidx.appcompat.app.AppCompatDelegate;

import java.lang.reflect.Constructor;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;

import cn.appblog.skindemo.R;
import cn.appblog.skindemo.view.ZeroView;

public class SkinFactory implements LayoutInflater.Factory2 {

    private AppCompatDelegate mDelegate; //预定义一个委托类,它负责按照系统的原有逻辑来创建View

    private List<SkinView> listCacheSkinView = new ArrayList<>(); //自定义的list,缓存所有可以换肤的View对象

    /**
     * 给外部提供一个set方法
     *
     * @param mDelegate
     */
    public void setDelegate(AppCompatDelegate mDelegate) {
        this.mDelegate = mDelegate;
    }

    /**
     * Factory2 是继承Factory的,所以,我们这次是主要重写Factory2的onCreateView逻辑,就不必理会Factory的重写方法了
     *
     * @param name
     * @param context
     * @param attrs
     * @return
     */
    @Override
    public View onCreateView(String name, Context context, AttributeSet attrs) {
        return null;
    }

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

        // TODO: 关键点1:执行系统代码里的创建View的过程,我们只是想加入自己的思想,并不是要全盘接管
        View view = mDelegate.createView(parent, name, context, attrs); //系统创建出来的时候有可能为空,你问为啥?请全文搜索 “标记标记,因为” 你会找到你要的答案
        if (view == null) { //万一系统创建出来是空,那么我们来补救
            mConstructorArgs[0] = context;
            try {
                if (-1 == name.indexOf('.')) { //不包含. 说明不带包名,那么我们帮他加上包名
                    view = createViewByPrefix(context, name, prefixs, attrs);
                } else { //包含. 说明 是权限定名的view name,
                    view = createViewByPrefix(context, name, null, attrs);
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }

        //TODO: 关键点2 收集需要换肤的View
        collectSkinView(context, attrs, view);

        return view;
    }

    /**
     * TODO: 收集需要换肤的控件
     * 收集的方式是:通过自定义属性isSupport,从创建出来的很多View中,找到支持换肤的那些,保存到map中
     */
    private void collectSkinView(Context context, AttributeSet attrs, View view) {
        // 获取我们自己定义的属性
        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.Skinable);
        boolean isSupport = a.getBoolean(R.styleable.Skinable_isSupport, false);
        if (isSupport) { //找到支持换肤的view
            final int Len = attrs.getAttributeCount();
            HashMap<String, String> attrMap = new HashMap<>();
            for (int i = 0; i < Len; i++) { //遍历所有属性
                String attrName = attrs.getAttributeName(i);
                String attrValue = attrs.getAttributeValue(i);
                attrMap.put(attrName, attrValue); //全部存起来
            }

            SkinView skinView = new SkinView();
            skinView.view = view;
            skinView.attrsMap = attrMap;
            listCacheSkinView.add(skinView);//将可换肤的view,放到listCacheSkinView中
        }

    }

    /**
     * 公开给外界的换肤入口
     */
    public void changeSkin() {
        for (SkinView skinView : listCacheSkinView) {
            skinView.changeSkin();
        }
    }

    static class SkinView {
        View view;
        HashMap<String, String> attrsMap;

        /**
         * 真正的换肤操作
         */
        public void changeSkin() {
            if (!TextUtils.isEmpty(attrsMap.get("background"))) { //属性名, 例如 background, text, textColor....
                Log.i("yezhou", view.getClass().getSimpleName() + ".background: " + attrsMap.get("background"));  //@2131099735
                int bgId = Integer.parseInt(attrsMap.get("background").substring(1)); //属性值, R.id.XXX, int类型
                //这个值,在app的一次运行中,不会发生变化
                String attrType = view.getResources().getResourceTypeName(bgId); //属性类别: 比如 drawable,color
                Log.i("yezhou", view.getClass().getSimpleName() + ".attrType: " + attrType);
                if (TextUtils.equals(attrType, "drawable")) { //区分drawable和color
                    view.setBackgroundDrawable(SkinEngine.getInstance().getDrawable(bgId)); //加载外部资源管理器, 拿到外部资源的drawable
                } else if (TextUtils.equals(attrType, "color")) {
                    view.setBackgroundColor(SkinEngine.getInstance().getColor(bgId));
                }
            }

            if (view instanceof TextView) {
                if (!TextUtils.isEmpty(attrsMap.get("textColor"))) {
                    int textColorId = Integer.parseInt(attrsMap.get("textColor").substring(1));
                    ((TextView) view).setTextColor(SkinEngine.getInstance().getColor(textColorId));
                }
            }

            //那么如果是自定义组件呢
            if (view instanceof ZeroView) {
                //那么这样一个对象, 要换肤, 就要写针对性的方法了, 每一个控件需要用什么样的方式去换, 尤其是那种, 自定义的属性, 怎么去set
                //这就对开发人员要求比较高了, 而且这个换肤接口还要暴露给自定义View的开发人员,他们去定义
                // ....
            }
        }
    }

    /**
     * 所谓hook, 要懂源码, 懂了之后再劫持系统逻辑, 加入自己的逻辑
     * 既然懂了, 系统的有些代码, 直接拿过来用, 也无可厚非
     */
    //*******************************下面一大片,都是从源码里面抄过来的,并不是我自主设计******************************
    // 你问我抄的哪里的?到 AppCompatViewInflater类源码里面去搜索:view = createViewFromTag(context, name, attrs);
    static final Class<?>[] mConstructorSignature = new Class[]{ Context.class, AttributeSet.class };
    final Object[] mConstructorArgs = new Object[2];//View的构造函数的2个"实"参对象
    private static final HashMap<String, Constructor<? extends View>> sConstructorMap = new HashMap<String, Constructor<? extends View>>();//用映射,将View的反射构造函数都存起来
    static final String[] prefixs = new String[]{ //安卓里面控件的包名,就这么3种,这个变量是为了下面代码里,反射创建类的class而预备的
            "android.widget.",
            "android.view.",
            "android.webkit."
    };

    /**
     * 反射创建View
     *
     * @param context
     * @param name
     * @param prefixs
     * @param attrs
     * @return
     */
    private final View createViewByPrefix(Context context, String name, String[] prefixs, AttributeSet attrs) {

        Constructor<? extends View> constructor = sConstructorMap.get(name);
        Class<? extends View> clazz = null;

        if (constructor == null) {
            try {
                if (prefixs != null && prefixs.length > 0) {
                    for (String prefix : prefixs) {
                        clazz = context.getClassLoader().loadClass(
                                prefix != null ? (prefix + name) : name).asSubclass(View.class);//控件
                        if (clazz != null) break;
                    }
                } else {
                    if (clazz == null) {
                        clazz = context.getClassLoader().loadClass(name).asSubclass(View.class);
                    }
                }
                if (clazz == null) {
                    return null;
                }
                constructor = clazz.getConstructor(mConstructorSignature);//拿到 构造方法,
            } catch (Exception e) {
                e.printStackTrace();
                return null;
            }
            constructor.setAccessible(true);//
            sConstructorMap.put(name, constructor);//然后缓存起来,下次再用,就直接从内存中去取
        }
        Object[] args = mConstructorArgs;
        args[1] = attrs;
        try {
            //通过反射创建View对象
            final View view = constructor.newInstance(args);//执行构造函数,拿到View对象
            return view;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }

}

自定义View

package cn.appblog.skindemo.view;

import android.content.Context;
import android.util.AttributeSet;
import android.view.View;

public class ZeroView extends View {
    public ZeroView(Context context) {
        super(context);
    }

    public ZeroView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

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

关键类 SkinEngine

package cn.appblog.skindemo.skin;

import android.content.Context;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.res.AssetManager;
import android.content.res.Resources;
import android.graphics.drawable.Drawable;
import android.util.Log;

import androidx.core.content.ContextCompat;

import java.io.File;
import java.lang.reflect.Method;

public class SkinEngine {

    //单例
    private final static SkinEngine instance = new SkinEngine();

    public static SkinEngine getInstance() {
        return instance;
    }

    private SkinEngine() {
    }

    public void init(Context context) {
        mContext = context.getApplicationContext();
        //使用application的目的是,如果万一传进来的是Activity对象
        //那么它被静态对象instance所持有,这个Activity就无法释放了
    }

    private Resources mOutResource; // TODO: 资源管理器
    private Context mContext; //上下文
    private String mOutPkgName; // TODO: 外部资源包的packageName

    /**
     * TODO: 加载外部资源包
     */
    public void load(final String path) { //path 是外部传入的apk文件名
        File file = new File(path);
        if (!file.exists()) {
            return;
        }
        //取得PackageManager引用
        PackageManager mPm = mContext.getPackageManager();
        //“检索在包归档文件中定义的应用程序包的总体信息”, 说人话, 外界传入了一个apk的文件路径, 这个方法, 拿到这个apk的包信息,这个包信息包含什么?
        PackageInfo mInfo = mPm.getPackageArchiveInfo(path, PackageManager.GET_ACTIVITIES);
        mOutPkgName = mInfo.packageName; //先把包名存起来
        AssetManager assetManager;//资源管理器
        try {
            //TODO: 关键技术点3 通过反射获取AssetManager 用来加载外面的资源包
            assetManager = AssetManager.class.newInstance(); //反射创建AssetManager对象, 为何要反射? 使用反射, 是因为他这个类内部的addAssetPath方法是hide状态
            //addAssetPath方法可以加载外部的资源包
            Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class); //为什么要反射执行这个方法? 因为它是hide的, 不直接对外开放, 只能反射调用
            addAssetPath.invoke(assetManager, path); //反射执行方法
            mOutResource = new Resources(assetManager, //参数1,资源管理器
                    mContext.getResources().getDisplayMetrics(), //这个好像是屏幕参数
                    mContext.getResources().getConfiguration()); //资源配置
            //最终创建出一个"外部资源包"mOutResource, 它的存在, 就是要让我们的app有能力加载外部的资源文件
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    /**
     * 提供外部资源包里面的颜色
     * @param resId
     * @return
     */
    public int getColor(int resId) {
        if (mOutResource == null) {
            return resId;
        }
        String resName = mOutResource.getResourceEntryName(resId);
        int outResId = mOutResource.getIdentifier(resName, "color", mOutPkgName);
        if (outResId == 0) {
            return resId;
        }
        return mOutResource.getColor(outResId);
    }

    /**
     * 提供外部资源包里的图片资源
     * @param resId
     * @return
     */
    public Drawable getDrawable(int resId) { //获取图片
        if (mOutResource == null) {
            return ContextCompat.getDrawable(mContext, resId);
        }
        String resName = mOutResource.getResourceEntryName(resId);
        int outResId = mOutResource.getIdentifier(resName, "drawable", mOutPkgName);
        Log.i("yezhou", "SkinEngine.resId: " + resId);
        Log.i("yezhou", "SkinEngine.outResId: " + outResId);
        if (outResId == 0) {
            return ContextCompat.getDrawable(mContext, resId);
        }
        return mOutResource.getDrawable(outResId);
    }

    //..... 这里还可以提供外部资源包里的String, font等等等,只不过要手动写代码来实现getXX方法
}

关键类的调用方式

初始化"换肤引擎"

package cn.appblog.skindemo.app;

import android.app.Application;

import cn.appblog.skindemo.skin.SkinEngine;

public class MyApp extends Application {

    @Override
    public void onCreate() {
        super.onCreate();
        //初始化换肤引擎
        SkinEngine.getInstance().init(this);
    }
}

劫持系统创建view的过程

package cn.appblog.skindemo.base;

import android.os.Bundle;
import android.os.Environment;
import android.util.Log;
import android.view.LayoutInflater;

import androidx.appcompat.app.AppCompatActivity;

import java.io.File;

import cn.appblog.skindemo.skin.SkinEngine;
import cn.appblog.skindemo.skin.SkinFactory;

/**
 * 把换肤的功能定义在这里
 */
public class BaseActivity extends AppCompatActivity {
    protected static String[] skins = new String[]{"blue_skin_plugin.apk", "orange_skin_plugin.apk"};

    protected static String mCurrentSkin = null;

    private SkinFactory mSkinFactory;

    private boolean ifAllowChangeSkin = true;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        // TODO: 关键点1:hook(劫持)系统创建view的过程
        if (ifAllowChangeSkin) {
            mSkinFactory = new SkinFactory();
            mSkinFactory.setDelegate(getDelegate());
            LayoutInflater layoutInflater = LayoutInflater.from(this);
            Log.d("layoutInflaterTag", layoutInflater.toString());
            layoutInflater.setFactory2(mSkinFactory);
        }
        super.onCreate(savedInstanceState);
    }

    /**
     * 创建完成但是还不可以交互
     */
    @Override
    protected void onStart() {
        super.onStart();
    }

    /**
     * 等控件创建完成并且可交互之后,再换肤
     */
    @Override
    protected void onResume() {
        super.onResume();
        Log.d("changeTag", null == mCurrentSkin ? "currentSkin是空" : mCurrentSkin);

        if (null != mCurrentSkin)
            changeSkin(mCurrentSkin); // 换肤操作必须在setContentView之后
    }

    /**
     * 做一个切换方法
     *
     * @return
     */
    protected String getPath() {
        String path;
        if (null == mCurrentSkin) {
            path = skins[0];
        } else if (skins[0].equals(mCurrentSkin)) {
            path = skins[1];
        } else if (skins[1].equals(mCurrentSkin)) {
            path = skins[0];
        } else {
            return "unknown skin";
        }
        return path;
    }

    protected void changeSkin(String path) {
        if (ifAllowChangeSkin) {
            File skinFile = new File(Environment.getExternalStorageDirectory(), path);
            SkinEngine.getInstance().load(skinFile.getAbsolutePath());
            mSkinFactory.changeSkin();
            mCurrentSkin = path;
        }
    }
}

执行换肤操作

package cn.appblog.skindemo.ui;

import android.content.Intent;
import android.content.pm.PackageManager;
import android.os.Bundle;
import android.view.View;

import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;

import cn.appblog.skindemo.R;
import cn.appblog.skindemo.base.BaseActivity;

public class MainActivity extends BaseActivity {

    private static final int REQUEST_EXTERNAL_STORAGE = 1;
    private static String[] PERMISSIONS_STORAGE = {
            "android.permission.READ_EXTERNAL_STORAGE",
            "android.permission.WRITE_EXTERNAL_STORAGE"}; //内存读写的权限,动态申请

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        findViewById(R.id.textView).setBackground(getDrawable(R.color.colorPrimary));

        verifyStoragePermissions(this);//申请权限
        findViewById(R.id.button).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                changeSkin(getPath());
            }
        });
        findViewById(R.id.button2).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                Intent intent = new Intent(MainActivity.this, Main2Activity.class);
                startActivity(intent);
            }
        });
    }

    /**
     * 申请权限,为了要把外部文件写入到 手机内存中
     *
     * @param activity
     */
    public static void verifyStoragePermissions(AppCompatActivity activity) {
        try {
            //检测是否有写的权限
            int permission = ActivityCompat.checkSelfPermission(activity,PERMISSIONS_STORAGE[1]);
            if (permission != PackageManager.PERMISSION_GRANTED) {
                //没有写的权限,去申请写的权限,会弹出对话框
                ActivityCompat.requestPermissions(activity, PERMISSIONS_STORAGE, REQUEST_EXTERNAL_STORAGE);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
package cn.appblog.skindemo.ui;

import android.os.Bundle;

import cn.appblog.skindemo.R;
import cn.appblog.skindemo.base.BaseActivity;

public class Main2Activity extends BaseActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main2);
    }

}

版权声明:
作者:Joe.Ye
链接:https://www.appblog.cn/index.php/2023/03/29/android-hook-technology-enables-one-click-skin-changing/
来源:APP全栈技术分享
文章版权归作者所有,未经允许请勿转载。

THE END
分享
二维码
打赏
海报
Android Hook技术实现一键换肤
产品要求app里面的图表要实现白天黑夜模式的切换,以满足不同光线下都能保证足够的图表清晰度。怎么办?可能解决的办法很多,可以给图表view增加一个toggle方……
<<上一篇
下一篇>>
文章目录
关闭
目 录