2020-06-10 手写Android 一键换肤

Hook换肤原理

安卓中的资源引用

常见的资源引用方式

  1. @packageName:type/name

    引用自己的资源: android:text="@string/hello"

  2. 引用系统公共资源

    引用系统资源: android:textColor="@android:color/opaque_red"

  3. 引用系统非公共资源

    引用安卓非公共资源android:testSize= @*android:type/name

  4. ? 引用主题属性

    ?[namespace:]type/name
    们使用一个"?"前缀代替了"@"。当你使用这个标记时,你就提供了属性资源的名称,它将会在主题中被查找,所以你不需要显示声明这个类型(如果声明,其形式就是?android:attr/android:textDisabledColor)。除了使用这个资源的标识符来查询主题中的值代替原始的资源,其命名语法和"@"形式一致:?[namespace:]type/name,这里类型可选

    android:textColor="?android:textDisabledColor"
    android:textColor="?android:attr/android:textDisabledColor"

  5. @+表示创建或者引用一个为定义的资源

    • ”+”表示在R.java中名为type的内部类中添加一条记录。如"@+id/button"的含义是在R.java 文件中的id 这个静态内部类添加一条常量名为button
    • @id/资源ID名 应用现有已定义的资源ID,包括系统ID
    • @android:id/资源ID名 引用系统ID,其等效于@id/资源ID名
        <TextView
           android:focusable="?attr/changeable" // 第四种情况,引用自己主题中的值
            android:textColorHint="@*android:color/holo_green_dark"// 使用系统非public资源
            android:textColor="@net.sunniwell.course.hook:color/colorBlack"// 使用自己定义的color, 如果是自己的包中资源,可以省略包名 虽然这里可以指定包名,但是如果你用其他应用的包名,是不行的, 
            android:text="@string/app_name"// 引用自己包中的资源省略包名
            android:background="@android:color/holo_green_dark" // 使用系统公共资源
            android:id="@+id/main_tv" // 创建一个新的资源id
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
    
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toTopOf="parent" />
    
  • 系统资源定义分public和非public。public的声明在:
    <sdk_path>\platforms\android-28\data\res\values\public.xml
  • @*android:type/name:可以调用系统定义的所有资源
  • @android:type/name:只能够调用publi属性的资源。

针对第四种情况,如果想引用自己设置的 需要遵循以下步骤:

  1. 在attrs 中声明自己的命名空间

    <?xml version="1.0" encoding="utf-8"?>
    <resources>
        <declare-styleable name="styleAble">
            <attr name="changeable" format="boolean"/>
        </declare-styleable>
    </resources>
    
  1. 在theme中对这个值进行声明

    
        <!-- Base application theme. -->
        <style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
            <!-- Customize your theme here. -->
            <item name="colorPrimary">@color/colorPrimary</item>
            <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
            <item name="colorAccent">@color/colorAccent</item>
            <item name="changeable">true</item>
        </style>
    
  1. 在控件中使用这个值

        <TextView
           android:focusable="?attr/changeable"
      />
    

尽量不要使用系统私有资源,这样是危险的

Theme Style attr 区别

  1. Thream 是指一系列的属性集合, 既可以包含Style也可以直接包含一个attr标签属性。 Theme指定的作用方向是一个应用或者一个Activity。 Theme是整个应用(或者Activity)所引用到的资源的集合。 通过Theme你可以设置 一些比较重要通用的属性。

​ 比如设置windowbackground, 设置listview的divider等

  1. Style主要是针对某一个控件的属性值, 例如设置一个控件的宽,高,背景等,style属性中的属性,都需要被控件可以识别。

  2. Theme可以使用者以及其子控件

  3. App的theme中包含各种控件的默认style,这样即使没有设置控件的style,那么也可以使用在app theme中

  4. 控件中指定style,那么执行的sytle就会覆盖Theme中默认的style。

Theme加载流程

  • Activity#setContentView
  • PhoneWindow#installDecor
  • PhoneWindow#generateLayout
  • Window#getWindowStyle
  • Context#obtainStyledAttributes
  • ContextThemeWapprer#getTheme
  • ContextThemeWapprer#setTheme
  • Resources#selectDefaultTheme
  • Resources#selectSystemTheme
  • Resources#onApplyThemeResource

----------------------------------------------------------------LEVEL1 --------------------------------------------------------------------------
Theme.AppCompat, Theme.AppCompat.Light, Theme.AppCompat.NoActionBar 等主题在这里被定义。开发者应该使用这些主题,而非那些更底层的。
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<style name="Theme.AppCompat.Light.DarkActionBar" parent="Base.Theme.AppCompat.Light.DarkActionBar"/> // 在 com.android.support:appcompat-v7:28.0.0


----------------------------------------------------------------LEVEL2 --------------------------------------------------------------------------
根据安卓的官方解释,我们在这一层拿到的主题只是第三层主题的别名:

      <style name="Base.Theme.AppCompat.Light.DarkActionBar" parent="Base.Theme.AppCompat.Light"> // 这个parent主题针对安卓进行了版本适配,在不同的版本中有不同的配置,这个主要是为了同安卓的原生主题进行适配。保证版本上的app主题同安卓主题一致。
          <style name="Base.Theme.AppCompat.Light" parent="Base.V21.Theme.AppCompat.Light"/>

----------------------------------------------------------------LEVEL3 --------------------------------------------------------------------------
大部分工作在这一层被完成,Base.V7.Theme.AppCompat, Base.V11.Theme.AppCompat, Base.V21.Theme.AppCompat 等也是在这一层被定义。这些主题都继承自 Platform.AppCompat。
绝大多数属性和几乎所有工作在 Base.V{api}.Theme.AppCompat 中被定义和完成。ActionBar, DropwDown, ActionMode, Panel, List, Spinner, Toolbar 等控件中的所有属性都在这里被定义。
<style name="Base.V21.Theme.AppCompat.Light" parent="Base.V7.Theme.AppCompat.Light">
<style name="Base.V7.Theme.AppCompat" parent="Platform.AppCompat">


----------------------------------------------------------------LEVEL4 --------------------------------------------------------------------------
​ <style name="Platform.AppCompat" parent="android:Theme.Holo"> Platformstyle也针对版本进行了适配。
​ <style name="Theme.Holo">// 当前版本的默认主题

Hook换肤

什么是换肤

有时候,我们的app主题是一个统一的颜色,但是为了配合其他一些场景(节日,促销等活动),会在特定的时间段再不改变app基本控件功能、位置的情况下,修改app的控件背景颜色,图片src,字体颜色,从而给用户一个新的体验。

为什么换肤

  • 保持app的新鲜感
  • 提高付费率
  • 配合节日,活动进行营销
  • apk瘦身
  • 写死的主题,切换很low

如何换肤

具体效果或者具体要求

  1. 不闪烁
  2. 不用启动
  3. 架构独立
  4. 无继承
  5. 易维护,可以多次复用

哪些东西可以替换

放在res目录下的东西基本都可以进行替换。 那么控件的大小可以进行设置吗? 等下我们试试。

  1. 背景颜色
  2. 文字颜色
  3. 图片
  4. 字体
  5. 动画
  6. 音频

Hook换肤的基本方案

  1. 通过定义多个theme, theme中使用不同的颜色或者图片,从而达到不同的效果。缺点: theme需要集成在app中,并且如果想要重新设置theme,需要重新启动app
  2. 统计app内需要设置换肤的控件,然后针对这些控件设置背景或者背景颜色。

View加载流程--->换肤

我们在Activity.setContentView之后,系统是如何把xml文件转换成一个个的View的呢?来吧, RTFS。

Xml解析成View流程.png
XML解析流程图.png
@SystemService(Context.LAYOUT_INFLATER_SERVICE)
public abstract class LayoutInflater {
    // these are optional, set by the caller
    private boolean mFactorySet; // 表示当前factory是否已经被设置了,只能设置一次
    private Factory mFactory;
    private Factory2 mFactory2;
    private Factory2 mPrivateFactory;
    
    
    // 通常我们直接通过这个方法获取到的inflater是系统直接创建好的。
 public static LayoutInflater from(Context context) {
        LayoutInflater LayoutInflater =
                (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
        if (LayoutInflater == null) {
            throw new AssertionError("LayoutInflater not found.");
        }
        return LayoutInflater;
    }
    
    // 这个方法中,setfactory 是设置mFactory
     public void setFactory(Factory 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 = factory;
        } else {// 如果当前的mFactory不为空,那么就进行合并 ,不要使用这个,因为这个可能出现空指针
            mFactory = new FactoryMerger(factory, null, mFactory, mFactory2);
        }
    }
   
    // 同样setFactory2
   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; // 如果mFactory为空,那么mFactory和mFactory2都用factory
        } else {
            mFactory = mFactory2 = new FactoryMerger(factory, factory, mFactory, mFactory2);
        }
    }
    
    // setPrivateFactory,这个方法是hide的,提供给framework使用
     public void setPrivateFactory(Factory2 factory) {
        if (mPrivateFactory == null) {
            mPrivateFactory = factory;
        } else {
            mPrivateFactory = new FactoryMerger(factory, factory, mPrivateFactory, mPrivateFactory);
        }
    }
    
    // 重要的inflate方法,将resource 填充到viewParent里面
   public View inflate(@LayoutRes int resource, @Nullable ViewGroup root) {
        return inflate(resource, root, root != null);
    }
    
 // 通过resId,生成一个xml解析器,使用xml pull解析
 public View inflate(@LayoutRes int resource, @Nullable ViewGroup root, boolean attachToRoot) {
        final Resources res = getContext().getResources();
        if (DEBUG) {
            Log.d(TAG, "INFLATING from resource: \"" + res.getResourceName(resource) + "\" ("
                    + Integer.toHexString(resource) + ")");
        }

        final XmlResourceParser parser = res.getLayout(resource);
        try {
            return inflate(parser, root, attachToRoot);
        } finally {
            parser.close();
        }
    }
    
    
// 最终调用到的方法:
public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
        synchronized (mConstructorArgs) {
            //  保存rootview
            View result = root;
       
                
                int type;
                // 首先将parse跳转到第一个开始的地方
                while ((type = parser.next()) != XmlPullParser.START_TAG &&
                        type != XmlPullParser.END_DOCUMENT) {
                    // Empty
                }

                if (type != XmlPullParser.START_TAG) {
                    throw new InflateException(parser.getPositionDescription()
                            + ": No start tag found!");
                }

                final String name = parser.getName();
            // 如果是merge标签,
                if (TAG_MERGE.equals(name)) {
                    if (root == null || !attachToRoot) {// 如果是merge标签,但是又没有根view,报错
                        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
                    // 通过createViewFromTag来生成一个tempView
                    //******************************重要方法*********************************//
                    final View temp = createViewFromTag(root, name, inflaterContext, attrs);

                    ViewGroup.LayoutParams params = null;
                // 通过addView,将 当前生成的View加入到Viewroot里面。
                    if (root != null && attachToRoot) {
                        root.addView(temp, params);
                    }

                    // 如果是添加到root里面,返回rootView, 否则返回tempView
                    if (root == null || !attachToRoot) {
                        result = temp;
                    }
            return result;
        }
    }
    
    
 //对于一个View标签,通过反射创建一个View,并把参数设置进去
    // parent  viewRoot
    //name  当前View 的标签
    // context 生成View 的context
    // 当前View下的属性标签集合
 View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
            boolean ignoreThemeAttr) {
       // 如果是一个blink标签, 那么就创建一个BliknLayout
     // 这个Blink其实就一个闪烁的标签 ,不要尝试使用它, 因为他一闪一闪的让人很难受,
        if (name.equals(TAG_1995)) {
            // Let's party like it's 1995!
            return new BlinkLayout(context, attrs);
        }

        try {
            View view;
            if (mFactory2 != null) {
                // 首先使用mFractory2来创建这个View
                view = mFactory2.onCreateView(parent, name, context, attrs);
            } else if (mFactory != null) {
                // 如果mFactory不为空,那么就是用mFactory来创建,不过一般情况下mFactory都是为空
                view = mFactory.onCreateView(name, context, attrs);
            } else {
                view = null;
            }

            if (view == null && mPrivateFactory != null) {
                // 如果上面都没有创建成功,就是用mPrivateFactory来创建
                view = mPrivateFactory.onCreateView(parent, name, context, attrs);
            }

            if (view == null) {
                final Object lastContext = mConstructorArgs[0];
                mConstructorArgs[0] = context;
                try {
                    if (-1 == name.indexOf('.')) {
                        // 如果直接是 Button, TextView这样的标签,  在这里创建了一些安卓基础控件 
                        view = onCreateView(parent, name, attrs);
                    } else {
                        // 如果是 android.support.constraint.ConstraintLayout 这样的标签,
                        view = createView(name, null, attrs);
                    }
                } finally {
                    mConstructorArgs[0] = lastContext;
                }
            }

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

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

        } catch (Exception e) {
            final InflateException ie = new InflateException(attrs.getPositionDescription()
                    + ": Error inflating class " + name, e);
            ie.setStackTrace(EMPTY_STACK_TRACE);
            throw ie;
        }
    }
    
 // 创建安卓基础控件
    // prefix android.view.
 public final View createView(String name, String prefix, AttributeSet attrs)
            throws ClassNotFoundException, InflateException {
        //首先获取这个View 的构造函数, 如果之前缓存过, 那么直接用,如果没有缓存过,那么就创建构造函数,并缓存
        // 这个也是安卓里面常用的缓存策略。
        Constructor<? extends View> constructor = sConstructorMap.get(name);
        if (constructor != null && !verifyClassLoader(constructor)) {// 这里还针对构造函数进行了验证,
            constructor = null;
            sConstructorMap.remove(name);
        }
        Class<? extends View> clazz = null;

        try {
      

            if (constructor == null) {
                // Class not found in the cache, see if it's real, and try to add it
                // 缓存池里面没有找到View的构造函数,所以需要先loadclass,并找到Constructor,并进行缓存
                clazz = mContext.getClassLoader().loadClass(
                        prefix != null ? (prefix + name) : name).asSubclass(View.class);

                if (mFilter != null && clazz != null) {
                    boolean allowed = mFilter.onLoadClass(clazz);
                    if (!allowed) {
                        failNotAllowed(name, prefix, 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 = mContext.getClassLoader().loadClass(
                                prefix != null ? (prefix + name) : name).asSubclass(View.class);

                        boolean allowed = clazz != null && mFilter.onLoadClass(clazz);
                        mFilterMap.put(name, allowed);
                        // 这里主要是过滤 是否可以inflate这个类,如果不能,那么就抛出异常
                        if (!allowed) {
                            failNotAllowed(name, prefix, attrs);
                        }
                    } else if (allowedState.equals(Boolean.FALSE)) {
                        failNotAllowed(name, prefix, attrs);
                    }
                }
            }

            Object lastContext = mConstructorArgs[0];
            if (mConstructorArgs[0] == null) {
                // Fill in the context if not already within inflation.
                mConstructorArgs[0] = mContext;
            }
            Object[] args = mConstructorArgs;
            args[1] = attrs;
        //  通过constructor.newInstance创建一个View实例。并且将View的attrs设置进去,保证View可以正常创建。
            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]));
            }
            mConstructorArgs[0] = lastContext;
            return view;

        } catch (NoSuchMethodException e) {
            final InflateException ie = new InflateException(attrs.getPositionDescription()
                    + ": 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(attrs.getPositionDescription()
                    + ": 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(
                    attrs.getPositionDescription() + ": Error inflating class "
                            + (clazz == null ? "<unknown>" : clazz.getName()), e);
            ie.setStackTrace(EMPTY_STACK_TRACE);
            throw ie;
        } finally {
            Trace.traceEnd(Trace.TRACE_TAG_VIEW);
        }
    }

    
}

APP资源加载引用流程

其实 app的资源是归 Resource来进行管理的。
Resource 里面包含了一个ResourceImpl, 而 ResourceImpl又包含了一个AssetManager。
AssetManager才是真正管理app资源的。


AssetManager.png

换肤方案

如果我们想实现好的换肤效果,那么我们需要解决以下几个问题

  1. 确定给哪些控件进行换肤

    我们可以给控件增加一些自定义属性,通过设置改变这个自定义属性的值,来确定是否可以换肤,以及给这个控件设置什么样的皮肤资源。

  2. 拿到需要换肤的控件

    拿到LayoutInflater, 给LayoutInflater设置一个Factory,通过Factory.onCreateView方法拦截View的生成,同时拿到需要换肤的View对象

  3. 拿到换肤的资源

    使用反射创建一个AssetManager,设置皮肤资源的地址,然后给VIew设置皮肤资源

  4. 给需要换肤的控件设置换肤资源

按照这个基本思路,加上我们上面的分析,我们开始进行解决。

针对AppCompateActivity


public class SWChangeSkinActivity extends AppCompatActivity {

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        // 在调用super.onCreate之后,已经将AppCompatDelegateImpl对象设置成为Inflater的factory2,




        // 在这一句里面,调用了getDelegate().installFactory

        // 先看getDelegate() 实际获取到的是 AppCompatDelegateImpl, 最后调用的AppCompateDelegateImpl.installFactory
     /*

    **************************AppCompatActivity***************************

        @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        final AppCompatDelegate delegate = getDelegate();
        delegate.installViewFactory();
        delegate.onCreate(savedInstanceState);
        if (delegate.applyDayNight() && mThemeId != 0) {
            // If DayNight has been applied, we need to re-apply the theme for
            // the changes to take effect. On API 23+, we should bypass
            // setTheme(), which will no-op if the theme ID is identical to the
            // current theme ID.
            if (Build.VERSION.SDK_INT >= 23) {
                onApplyThemeResource(getTheme(), mThemeId, false);
            } else {
                setTheme(mThemeId);
            }
        }
        super.onCreate(savedInstanceState);
    }




      public AppCompatDelegate getDelegate() {
            if (mDelegate == null) {
                mDelegate = AppCompatDelegate.create(this, this);
            }
            return mDelegate;
        }
    **************************AppCompatDelegate***************************
          public static AppCompatDelegate create(Activity activity, AppCompatCallback callback) {
        return new AppCompatDelegateImpl(activity, activity.getWindow(), callback);
    }


    **************************AppCompatDelegateImpl***************************

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


        */


        setContentView(R.layout.activity_main);
        //第二句 setcontentView 这个方法,走的是 AppCompatDelegateImpl中的 setcontentView

     /*
        **************************AppCompatDelegateImpl***************************
       @Override
    public void setContentView(int resId) {
        ensureSubDecor();
        ViewGroup contentParent = (ViewGroup) mSubDecor.findViewById(android.R.id.content);
        contentParent.removeAllViews();
        LayoutInflater.from(mContext).inflate(resId, contentParent); // 这里就是将xml资源转换成View的关键代码
        mOriginalWindowCallback.onContentChanged();
    }
        */
    }
}

所以,从上面的代码来看,我们针对appcompatDelegateActivity 在设置layoutfactory的时候,需要在super.onCreate之前进行调用。

代码详解

  1. 首先需要我们自定义属性,这样方便我们进行之后的收集,同时也可以指定更改什么资源

    <?xml version="1.0" encoding="utf-8"?>
    <resources>
    
      <!--  创建自定义属性,表示当前ViewView是否可以进行theme更改-->
        <declare-styleable name="Skinable">
            <attr name="themeChange" format="string"/>// 可以修改哪些主题
            <attr name="isSupport" format="boolean"> // 是否支持进行主题修改
    
            </attr>
        </declare-styleable>
    </resources>
    
    
    ------------------------------------------------------------------------------------------
    
    <?xml version="1.0" encoding="utf-8"?>
    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        xmlns:tools="http://schemas.android.com/tools"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/colorAccent"
        app:themeChange = "background"
        android:orientation="vertical"
        app:isSupport="true" // 定义当前VIew支持修改主题
        tools:context=".MainActivity">
    
        <TextView
            android:background="@color/colorPrimary"
            app:isSupport="true"
            app:themeChange = "background.textcolor"// 定义当前view可以i修改 背景颜色和字体颜色。
            android:layout_width="120dp"
            android:layout_height="40dp"
            android:gravity="center"
            android:text="Hello"
            android:textColor="@color/text_white" />
    
        <ImageView
            app:themeChange = "src"
            app:isSupport="true"
            android:layout_width="200dp"
            android:layout_height="200dp"
            android:background="@color/white"
            android:src="@drawable/beauty1" />
    
        <Button
            app:themeChange = "background"
            app:isSupport="true"
            android:background="@color/colorPrimary"
            android:id="@+id/main_btn_change_them"
            android:layout_width="120dp"
            android:layout_height="50dp"
            android:text="ChangeTheme" />
    
    </LinearLayout>
    
    
    
    
    
    
    
    
  1. 创建自定义的LayoutInflater.Factory

    package net.sunniwell.course.hooktheme.hook;
    
    import android.content.Context;
    import android.content.res.TypedArray;
    import android.os.SystemClock;
    import android.support.v7.app.AppCompatDelegate;
    import android.text.TextUtils;
    import android.util.AttributeSet;
    import android.util.Log;
    import android.view.InflateException;
    import android.view.LayoutInflater;
    import android.view.View;
    import android.view.ViewStub;
    import android.widget.ImageView;
    import android.widget.TextView;
    
    import net.sunniwell.course.hooktheme.R;
    
    import java.lang.reflect.Constructor;
    import java.util.ArrayList;
    import java.util.HashMap;
    import java.util.jar.Attributes;
    
    
    /**
     * 通过自定义LayoutInflater来进行View创建。
     */
    public class SWLayoutFactory implements LayoutInflater.Factory2 {
        private static final String TAG = "SWLayoutFactory";
    
    
        private AppCompatDelegate mDelegate;
    
        public SWLayoutFactory(AppCompatDelegate delegate) {
    
            mDelegate = delegate;
        }
    
        private ArrayList<ThemedView> viewToChangeBackground = new ArrayList<>();
        private ArrayList<ThemedView> viewToChangeImageSource = new ArrayList<>();
        private ArrayList<ThemedView> viewToChangeTextColor = new ArrayList<>();
    
    
        /**
         * 参考PhoneLayoutInflater
         */
        private static final String[] sClassPrefixList = {
                "android.widget.",
                "android.webkit.",
                "android.app."
        };
    
        /**
         * Empty stack trace used to avoid log spam in re-throw exceptions.
         */
        private static final StackTraceElement[] EMPTY_STACK_TRACE = new StackTraceElement[0];
    
        final Object[] mConstructorArgs = new Object[2];
    
        static final Class<?>[] mConstructorSignature = new Class[]{
                Context.class, AttributeSet.class};
    
        private static final HashMap<String, Constructor<? extends View>> sConstructorMap =
                new HashMap<String, Constructor<? extends View>>();
    
    
        /**
         * 从LayoutInflater.createViewFromTag  这个函数中,我们可以看到,只会调用这个方法, 第二个方法可以不管
         *
         * @param parent
         * @param name
         * @param context
         * @param attrs
         * @return
         */
        @Override
        public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
            View v = mDelegate.createView(parent, name, context, attrs);
            // 首先指定使用父类的进行创建,这样避免出现appcompat类中无法进行适配的问题。
            if (v != null) {
                collectThemeChangeableView(context, v, attrs);
                return v;
            }
         // 如果父类无法正常创建,我们来接管创建流程
            if (name.indexOf(".") == -1) {// 如果是 Button, text这样的view,需要添加前缀
                for (String prefix : sClassPrefixList) {
                    try {
                        View view = createView(context, name, prefix, attrs);
                        if (view != null) {
                            return view;
                        }
                    } catch (Exception e) {
                        // In this case we want to let the base class take a crack
                        // at it.
                    }
                }
    
            } else {
                // 其他的都是不用添加前缀,
                try {
                    View view = createView(context, name, null, attrs);
                    if (view != null) {
                        return view;
                    }
                } catch (Exception e) {
                    // In this case we want to let the base class take a crack
                    // at it.
                }
            }
    
         // 如果我们自己没办法创建,交给系统创建
            return null;
        }
    
    
        /**
         * 这个方法我们可以不进行处理,没有调用到这个方法。
         *
         * @param name
         * @param context
         * @param attrs
         * @return
         */
        @Override
        public View onCreateView(String name, Context context, AttributeSet attrs) {
            return null;
        }
    
    
        // 我们自定义的view创建类,具体流程可以参见PhoneLayoutInflater.java,LayoutInflater.java
        public final View createView(Context ctx, String name, String prefix, AttributeSet attrs) {
            Constructor<? extends View> constructor = sConstructorMap.get(name);
            Class<? extends View> clazz = null;
            try {
                if (constructor == null) {
                    // Class not found in the cache, see if it's real, and try to add it
                    clazz = ctx.getClassLoader().loadClass(
                            prefix != null ? (prefix + name) : name).asSubclass(View.class);
    
    
                    constructor = clazz.getConstructor(mConstructorSignature);
                    constructor.setAccessible(true);
                    sConstructorMap.put(name, constructor);
                }
    
                Object lastContext = mConstructorArgs[0];
                if (mConstructorArgs[0] == null) {
                    // Fill in the context if not already within inflation.
                    mConstructorArgs[0] = ctx;
                }
                Object[] args = mConstructorArgs;
                args[1] = attrs;
    
                final View view = constructor.newInstance(args);
    
                mConstructorArgs[0] = lastContext;
                // TODO 在这里针对View进行统计
    
                collectThemeChangeableView(ctx, view, attrs);
                return view;
    
            } catch (NoSuchMethodException e) {
                final InflateException ie = new InflateException(attrs.getPositionDescription()
                        + ": Error inflating class " + (prefix != null ? (prefix + name) : name), e);
                ie.setStackTrace(EMPTY_STACK_TRACE);
                Log.d(TAG, "createView:  ie:" + ie.toString());
    
            } catch (ClassCastException e) {
                // If loaded class is not a View subclass
                final InflateException ie = new InflateException(attrs.getPositionDescription()
                        + ": Class is not a View " + (prefix != null ? (prefix + name) : name), e);
                ie.setStackTrace(EMPTY_STACK_TRACE);
                Log.d(TAG, "createView:  ie:" + ie.toString());
            } catch (ClassNotFoundException e) {
                // If loadClass fails, we should propagate the exception.
                Log.d(TAG, "createView:  ie:" + e.toString());
            } catch (Exception e) {
                final InflateException ie = new InflateException(
                        attrs.getPositionDescription() + ": Error inflating class "
                                + (clazz == null ? "<unknown>" : clazz.getName()), e);
                ie.setStackTrace(EMPTY_STACK_TRACE);
                Log.d(TAG, "createView:  ie:" + ie.toString());
            } finally {
    
            }
            return null;
        }
    
    
        /**
         * 到这里,我们已经基本确定了哪些View需要执行一键换肤,以及这些view需要更改什么资源,到这里第二大步骤已经完成了
         * 接下来我们需要进行第三步的处理, 加载外部的主题资源 ,加载外部的主体资源主要是通过Resource这个类完成的。
         *
         * @param ctx
         * @param v
         * @param attrs
         */
        private void collectThemeChangeableView(Context ctx, View v, AttributeSet attrs) {
            TypedArray typedArray = ctx.obtainStyledAttributes(attrs, R.styleable.Skinable);
            boolean b = typedArray.getBoolean(R.styleable.Skinable_isSupport, false);
            // 如果这个view包含我们自定义的支持换肤的属性,那么就需要进行解析。
            if (b) {
                String s = typedArray.getString(R.styleable.Skinable_themeChange);
                Log.d(TAG, "collectThemeChangeableView:  start to collect ---------------------------");
                if (!TextUtils.isEmpty(s)) {
                    int attributeCount = attrs.getAttributeCount();
                    HashMap<String, String> attrMap = new HashMap<>();
                    for (int i = 0; i < attributeCount; i++) {//遍历所有属性
                        String attrName = attrs.getAttributeName(i);
                        String attrValue = attrs.getAttributeValue(i);
                        Log.d(TAG, "collectThemeChangeableView: attrName;" + attrName + ";attrValue:" + attrValue);
                        attrMap.put(attrName, attrValue);//全部存起来
                    }
    
                    boolean containPoint = s.contains(".");
                    int count = 1;
                    String[] split = new String[]{s};
                    if (containPoint) {
                        split = s.split("\\.");
                        count = split.length;
                    }
    
             // 根据定义中的 background ,textcolor,  src 等来确定需要更改哪些主题资源
                    for (int i = 0; i < count; i++) {
                        if (split[i].equals("background")) {
                            String attrValue = attrMap.get("background");
                            viewToChangeBackground.add(new ThemedView(v, attrValue));
                        } else if (split[i].equals("src")) {
                            String attrValue = attrMap.get("src");
                            viewToChangeImageSource.add(new ThemedView(v, attrValue));
                        } else if (split[i].equals("textcolor")) {
                            String attrValue = attrMap.get("textColor");
                            viewToChangeTextColor.add(new ThemedView(v, attrValue));
                        }
                        /*....下面可以针对这些进行更多的适配。.....*/
                    }
    
    
                }
            }
            Log.d(TAG, "collectThemeChangeableView:  end to collect --------------------------- \n");
            typedArray.recycle();
    
        }
    
    
        public void changeTheme() {
    
            // 我们现在只统计了三个部分,  background textcolor src
            Log.d(TAG, "changeTheme:  " + SystemClock.currentThreadTimeMillis() + "\n");
            for (int i = 0; i < viewToChangeTextColor.size(); i++) {
    
                ThemedView v = viewToChangeTextColor.get(i);
    
                int bgId = Integer.parseInt(v.resourceId.substring(1));//属性值,R.id.XXX ,int类型,
                ((TextView) v.mView).setTextColor(ThemeResourceEngine.getInstance().getColor(bgId));
                Log.d(TAG, "changeTheme: changeTextColor: " + i + "; resId:" + v.resourceId);
            }
            for (int i = 0; i < viewToChangeImageSource.size(); i++) {
                ThemedView v = viewToChangeImageSource.get(i);
                int bgId = Integer.parseInt(v.resourceId.substring(1));//属性值,R.id.XXX ,int类型,
                Log.d(TAG, "changeTheme: setImageResource: " + i + "; resId:" + v.resourceId);
                ImageView iv =  ((ImageView) v.mView);
               iv.setImageDrawable(ThemeResourceEngine.getInstance().getDrawable(bgId));
    
            }
            for (int i = 0; i < viewToChangeBackground.size(); i++) {
                ThemedView v = viewToChangeBackground.get(i);
                int bgId = Integer.parseInt(v.resourceId.substring(1));//属性值,R.id.XXX ,int类型,
                Log.d(TAG, "changeTheme: setBackgroundColor: " + i + "; resId:" + v.resourceId);
                String attrType = v.mView.getResources().getResourceTypeName(bgId); // 属性类别:比如 drawable ,color
                Log.d(TAG, "changeTheme: attrType:" + attrType);
                if (TextUtils.equals(attrType, "drawable")) {//区分drawable和color
                    v.mView.setBackgroundDrawable(ThemeResourceEngine.getInstance().getDrawable(bgId));//加载外部资源管理器,拿到外部资源的drawable
                } else if (TextUtils.equals(attrType, "color")) {
                    v.mView.setBackgroundColor(ThemeResourceEngine.getInstance().getColor(bgId));
                }
    
            }
    
    
            Log.d(TAG, "changeTheme:  " + SystemClock.currentThreadTimeMillis());
        }
    
    
        /**
         * 因为我们后面需要根据原来的资源id来获取到新的资源id,所以,我们必须要保存和这个view同步的attrs
         */
        private class ThemedView {
            // 保留原来的属性值,这样可以非常方便的进行主题修改。
            public View mView;
            public String resourceId;
    
            public ThemedView(View v, String a) {
                mView = v;
                resourceId = a;
            }
        }
    
    
    }
    
    
  1. 创建外部资源加载类

    加载外部的主题资源。

    package net.sunniwell.course.hooktheme.hook;
    
    
    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.support.v4.content.ContextCompat;
    import android.text.TextUtils;
    import android.util.Log;
    
    import java.io.File;
    import java.lang.reflect.Constructor;
    import java.lang.reflect.InvocationTargetException;
    import java.lang.reflect.Method;
    
    /**
     * 主要用来加载外部的主题资源。
     */
    public class ThemeResourceEngine {
        private static final String TAG = "ThemeResourceEngine";
    
    
        private static ThemeResourceEngine mInstance;
    
        private ThemeResourceEngine() {
    
        }
    
        public static ThemeResourceEngine getInstance() {
            if (mInstance == null) {
                synchronized (ThemeResourceEngine.class) {
                    if (mInstance == null) {
                        mInstance = new ThemeResourceEngine();
                    }
                }
            }
            return mInstance;
        }
    
    
        private Resources mOutResource;
        private String outPackageName;
        private Context mCtx;
    
    
        public void loadResource(String path, Context ctx) {
            if(TextUtils.isEmpty(path)){
                Log.d(TAG, "loadResource:  path is emgpt");
            }
            File cacheFile =ctx.getCacheDir();
            File apkFile = new File(cacheFile,"app-debug.apk");
            if(apkFile.exists()){
                Log.d(TAG, "loadResource: current apkFile exist:"+ apkFile.getAbsolutePath());
            }else{
                Log.d(TAG, "loadResource: apkfile not exist");
                return;
            }
            path = apkFile.getAbsolutePath();
            mCtx = ctx;
    
            AssetManager manager = null;
            try {
                PackageManager packageManager = ctx.getPackageManager();
                PackageInfo packageArchiveInfo = packageManager.getPackageArchiveInfo(path, PackageManager.GET_ACTIVITIES);
                outPackageName = packageArchiveInfo.packageName;
                Log.d(TAG, "loadResource: getPackagename: " +outPackageName);
    
                Constructor<AssetManager> constructor = AssetManager.class.getConstructor(null);
                constructor.setAccessible(true);
                manager = constructor.newInstance(null);
                Method addAssetPath = manager.getClass().getMethod("addAssetPath", String.class);//为什么要反射执行这个方法?因为它是hide的,不直接对外开放,只能反射调用
                addAssetPath.invoke(manager, path);//反射执行方法
                mOutResource = new Resources(manager, ctx.getResources().getDisplayMetrics(), ctx.getResources().getConfiguration());
                Log.d(TAG, "loadResource:  outResource: " + mOutResource);
    
            } catch (NoSuchMethodException e) {
                e.printStackTrace();
            } catch (IllegalAccessException e) {
                e.printStackTrace();
            } catch (InstantiationException e) {
                e.printStackTrace();
            } catch (InvocationTargetException e) {
                e.printStackTrace();
            }
    
    
        }
    
    
        public int getColor(int res) {
    
            if (mOutResource == null) {
                return res;
            }
            Log.d(TAG, "getColor:  outColor is not null:");
    
            String name = mOutResource.getResourceEntryName(res);
            int outResId = mOutResource.getIdentifier(name, "color", outPackageName);
            if (outResId == 0) {
                return res;
            }
    
    
            return mOutResource.getColor(outResId);
        }
    
    
    
    
        /**
         * 提供外部资源包里的图片资源
         *
         * @param resId
         * @return
         */
        public Drawable getDrawable(int resId) {//获取图片
            if (mOutResource == null) {
                return ContextCompat.getDrawable(mCtx, resId);
            }
            String resName = mOutResource.getResourceEntryName(resId);
            int outResId = mOutResource.getIdentifier(resName, "drawable", outPackageName);
            if (outResId == 0) {
                return ContextCompat.getDrawable(mCtx, resId);
            }
            return mOutResource.getDrawable(outResId);
        }
    }
    
    
  1. 使用:

    public class SWApplication extends Application {
    
        @Override
        public void onCreate() {
            super.onCreate();
            ThemeResourceEngine.getInstance().loadResource("", this);
        }
    }
    --------------------------------------------------
        
        public class MainActivity extends AppCompatActivity {
        private static final String TAG = "MainActivity";
    
        private SWLayoutFactory mLayoutFactory;
    
    
        @Override
        protected void onCreate(Bundle savedInstanceState) {
    
            changeLaoutInflaterFactory2(LayoutInflater.from(MainActivity.this));
            super.onCreate(savedInstanceState);
            setContentView(R.layout.activity_main);
    
            findViewById(R.id.main_btn_change_them).setOnClickListener(new View.OnClickListener() {
                @Override
                public void onClick(View v) {
                    Log.d(TAG, "onClick: onButtonClick");
                    mLayoutFactory.changeTheme();
                }
            });
        }
    
    
        private void changeDelegate() {
    
            final AppCompatDelegate delegate = getDelegate();
            Log.d(TAG, "changeDelegate: Class:" + delegate.getClass());
            Object proxy = Proxy.newProxyInstance(getClassLoader(), new Class[]{LayoutInflater.Factory2.class, MenuBuilder.Callback.class}, new InvocationHandler() {
                @Override
                public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
                    Log.d(TAG, "methodName:" + method.getName());
                    return method.invoke(delegate, args);
                }
            });
            //    private AppCompatDelegate mDelegate;
    
            try {
                Field f = AppCompatActivity.class.getDeclaredField("mDelegate");
                f.setAccessible(true);
                Object originalDelegate = f.get(this);
                f.set(this, proxy);
    
            } catch (NoSuchFieldException e) {
                e.printStackTrace();
            } catch (IllegalAccessException e) {
                e.printStackTrace();
            }
    
    
        }
    
    
        /**
         * 第一大步 首先将LayoutInflater的factory2换成我们自己定义的LayoutInflater,通过自定义LayoutInflater来截获
         * View的生成过程,在这个过程中统计目前支持换肤的元素
         *
         * @param inflater
         */
        private void changeLaoutInflaterFactory2(LayoutInflater inflater) {
            if (inflater == null) {
                Log.d(TAG, "sorry, current layoutinflater is null");
                return;
            }
            if (mLayoutFactory == null) {
                mLayoutFactory = new SWLayoutFactory(getDelegate());
            }
            Log.d(TAG, "changeLaoutInflaterFactory2: inflaterCalss: " + inflater.getClass());
            Field f = Util.getField(inflater.getClass(), "mFactorySet");
            boolean o = true;
            try {
                o = (boolean) f.get(inflater);
                Log.d(TAG, "changeLaoutInflaterFactory2:  mFactorySet:" + o);
            } catch (IllegalAccessException e) {
                e.printStackTrace();
            }
    
            LayoutInflater.Factory2 factory2 = inflater.getFactory2();
            Log.d(TAG, "changeLaoutInflaterFactory2:  factory2:" + factory2);
    
            if (!o) {
                inflater.setFactory2(mLayoutFactory);
            } else {
                if (Util.setField(inflater, "mFactorySet", false)) {
                    inflater.setFactory2(mLayoutFactory);
                }
            }
    
    
        }
    
    
    }
        
    

参考文章

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,723评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,485评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,998评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,323评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,355评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,079评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,389评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,019评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,519评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,971评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,100评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,738评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,293评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,289评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,517评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,547评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,834评论 2 345