Android Hook 换肤系列。

前提

最近苹果手机发布黑夜模式,要求app必须实现黑白两种布局,虽然是苹果公司的要求,但是对于一个android开发者来说,肯定会思考,如果换成Android APP,我们又该怎么实现呢。

于是便有了这篇andrid Hook换肤的文章,可能会有不对之处,欢迎指出一起探讨。本篇将通过也将通过以下四点来解密android换肤的全过程。

本文大纲

  • 何为换肤
  • xml的view是如何加载到activity之上的
  • 如何根据apk中的资源找到要替换的资源以及其中的原理。
  • 如何执行换肤

一丶何为换肤

换肤的过程,其实就是收集应用中所有的view,然后更换这些view的属性,比如背景颜色,字体颜色,状态栏等信息。 在xml的加载到activity过程中,我们通过hook技术,获取到我们需要的view,然后保存这些view的节点信息。这时候加载提前准备好的资源apk文件,通过获取前面view保存的id,来获取对应的name,然后再通过name去加载apk中对应的资源,完成替换的过程

说了这么多,可能很多人还是云里雾里,比喻xml是如何加载到acitivyt之中?我们又是怎么拿到需要换肤的view属性?资源文件是通过哪种方式保存?以及最后如何完成换肤的替换操作? 本文就是为了解决这些问题,还请耐心的看下去。

二丶xml的view是如何加载到activity之上的

对于这个疑问,不用说我们肯定是要往源码层级追了,我将尽量的将重要代码简单的呈现出来,希望大部分人有耐心和我一起完成xml被加载之旅,那么现在出发吧。

追溯这个问题,我们第一反应肯定是Activity.setContentView()方法,在这个方法中,我们会将我们的资源文件传入,那么我们就从这里开始,首先打开Activity.setContentView();

public void setContentView(@LayoutRes int layoutResID) {
      getWindow().setContentView(layoutResID);
      initWindowDecorActionBar();
}

如果对源码熟悉的同学,看到getWindow()我们首先想到的就是window这个抽象类,而这个类的唯一实现就是PhoneWindow,所以调用Activity的setContentView()其实就是调用PhoneWindow的setContentView();那么我们将视线移步PhoneWindow;

@Override
public void setContentView(int layoutResID) {
    if (mContentParent == null) {
        //第一次进来mContentParent肯定为null,先初始化decorview
        installDecor();
    } 
    if (hasFeature(FEATURE_CONTENT_TRANSITIONS)) {
        final Scene newScene = Scene.getSceneForLayout(mContentParent, layoutResID,
                getContext());
        transitionTo(newScene);
    } else {
        //通过inflate加载布局
        mLayoutInflater.inflate(layoutResID, mContentParent);
    }
}

首先会调用installDecor(); 这个方法就是初始化decorView的操作,而这个DecorView其实就是一个FrameLayout在我们布局之上。新建项目的时候都有主题的,decorview就是主题,我们重点不关注这个方法,主要看下面的mLayoutInflater.inflate(layoutResID, mContentParent)方法,而我们追踪inflate会发现,调用的最终还是LayoutInflater的inflate方法,而在这个方法中,我们只关注一下createViewFromTag

// Temp is the root view that was found in the xml
final View temp = createViewFromTag(root, name, inflaterContext, attrs);
   
    @UnsupportedAppUsage
    View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
            boolean ignoreThemeAttr) {

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

仔细阅读createViewFromTag的源码,其实核心只做了两件事

  • createView 所有的view最终都会调用到这个方法,来创建view,具体过程下面分析
  • tryCreateView 可以理解为系统预留给我们开发者提供的创建view方式,通过Factory2接口来实现具体逻辑,如果实现了我们的逻辑,就不会在调用系统的方法。

而我们的换肤核心就是要hook上面的代码执行逻辑,在createview之前,创建Factory2然后实现onCreateview方法,来收集我们需要换肤的view。

@UnsupportedAppUsage
static final Class[] mConstructorSignature = new Class[] {
            Context.class, AttributeSet.class};

public final View createView(@NonNull Context viewContext, @NonNull String name,
           @Nullable String prefix, @Nullable AttributeSet attrs)
           throws ClassNotFoundException, InflateException {   
    try {
        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);
                }
            }
            //传入两个构造方法,来反射view。
            constructor = clazz.getConstructor(mConstructorSignature);
            constructor.setAccessible(true);
            sConstructorMap.put(name, constructor);
        } 
}

tryCreateView 代码:

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;
    //可以通过重新mFactory2,来hook,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;
}

到目前位置我们已经知道,如何将一个xml资源文件加载到布局上,以及如何把他们变成对应的对象。而我们换肤思想的第一步,就是要在view的加载过程中,收集到这些view的信息,以及所有需要换肤的view,然后保存起来等待我们换肤的时候使用:

所以总结一下:

  1. 首先会调用PhoneWindow的 setContentView
  2. 在phoneWindow的setcontentview中,会初始化dectorView来加载主题。
  3. 加载完主题,然后调用LayoutInflate.inflate方法。
  4. 在inflate的方法中,会通过调用crateViewFroamTag来创建View
  5. 所有View最终都会通过createView来创建对象
  6. 创建对象的过程是通过view的第二个构造方法反射获取。
  7. 在createview之前,Google源码中会有对开发中暴露出来的一个Factory2工厂类,在创建view之前,会先调用一下tryCreateView方法,来判断我们的Factory2是否为空,如果不为空就开始使用我们自己的接口创建view。所以这一步是换肤的关键步骤,实现Factory2接口来收集需要换肤的view。
  8. 所以到目前为止,我们有两种方案来换肤
    1. 通过hook源码的过程来hook crateview的过程但是这个过程侵入性太高不建议采用
    2. 我们观察到在crateview之前,会有一个队Factory的判空,我们可以利用这个接口,来做hook操作,收集到所有需要换肤的button。

三 如何根据apk中的资源找到要替换的资源以及其中的原理。

其实想要弄清楚这个问题,我们还需要知道皮肤包到底是怎样生成的呢。

   ##### 皮肤包的生成

我们执行换肤操作时,需要更换的皮肤其实是从另外一个apk中加载过来,而这个apk是开发者提前准备好, 或者从网络下载下来。我们不需要里面有任何的java代码,只需要把我们要替换的资源的src文件保留,且保证key值相同。也就是说在最初的apk中,保存TextView字体颜色的key = textColor,那么在加载的apk中我们的添加的key也要 = textColor,只不过我们改了这个textColor对应的颜色值。

如何找到要替换的资源

应用中所有资源文件都被保存到一个resource.arsc文件中,里面包括id,name等信息。我们通过调用原始apk的Resources的api来获取name,type

getResourcesEntryName(id)// 获取resname

getResourcesTypeName(id)// 获取resType

然后根据获取的结果,来到皮肤包apk中寻找,也是调用Resources的api

skinId = getIdentifier(resname,resType,mSkinPkgName)//第三个参数就是我们皮肤包的路径 返回一个skinid

如果这个skinId不为0 ,说明我们在换肤apk中找到了要更换的资源,就可以完成替换。

原理

整个换肤过程中,其实有一个关键的类AssentManager,就是这个类来管理和获取resource.arsc。可能很多人会问为什么我知道是AssentManager呢,这里面就涉及到AMS的源码,本文不做深入研究。

四 如何执行换肤(整个换肤设计思路)

  1. 首先我们知道了xml的加载过程,所以我们需要实现一个Factory2接口,在这个接口中我们要重写createView的过程,在重新的过程中,我们需要收集view的基本信息

  2. 我们每一个xml里面的view,本身都包含大量的属性,size,color,drawable,等等,我们需要创建一个view的bean类,来保存这些信息。

    因为view的属性有很多,所以需要一个集合来保存每一个view的属性,所以我们在创建一个view的类,增加两个成员属性,view以及view属性集合

    换肤的过程是给所有的view换肤,所有还需要一个类来保存所有的view信息。

  3. 我们需要完成在全局任何部位,无闪烁的换肤,所以需要Factory实现系统的被观察者接口,在所有需要换肤的地方,调用接口实现换肤。

    而我们的观察者就是每一个activity,我们可以利用application中的ActivityLifecycleCallbacks接口,来监听activity生命周期,在每一个acitivity初始化的时候,来注册观察者。然后在需要换肤时候通知被观察者。

五 细节

  1. 实现Factory2接口以后,需要调用setFactory给Factory赋值,但是根据源码可以看到,让我们赋值一次以后,就不能在继续调用 了,所以需要才用hook继续来反射拿到mFactoryset属性,动态改变它的值。
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);
    }
}

你可能感兴趣的:(Android Hook 换肤系列。)