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控件呢?

源码索引:

1
2
3
4
@Override
public void setContentView(@LayoutRes int layoutResID) {
getDelegate().setContentView(layoutResID);
}

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

1
2
3
4
5
6
7
@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的具体过程

1
2
3
4
5
6
7
8
@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之后,又干了什么事呢?
一路索引进去:会发现这个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
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 ....>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
@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,发现一段关键代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@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对象是哪来的?什么时候设置进去的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
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 */
);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
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

1
2
3
4
@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并不满足….具体原因暂时也没查出来

1
2
3
4
5
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的下面这段代码:

1
2
3
4
5
6
7
8
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;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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)都最终索引到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
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

1
2
3
4
5
6
<?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

1
2
3
4
5
6
7
<?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

1
2
3
4
5
6
<?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

1
2
3
4
5
6
<?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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
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方法
}

关键类的调用方式

初始化”换肤引擎”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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的过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
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;
}
}
}

执行换肤操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
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();
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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);
}

}

Powered by AppBlog.CN     浙ICP备14037229号

Copyright © 2012 - 2020 APP开发技术博客 All Rights Reserved.

访客数 : | 访问量 :