您的位置:首页 > 移动开发 > Android开发

手把手讲解 Android hook技术实现一键换肤

2019-02-18 14:49 120 查看

前言

手把手讲解系列文章,是我写给各位看官,也是写给我自己的。
文章可能过分详细,但是这是为了帮助到尽量多的人,毕竟工作5,6年,不能老吸血,也到了回馈开源的时候.
这个系列的文章:
1、用通俗易懂的讲解方式,讲解一门技术的实用价值
2、详细书写源码的追踪,源码截图,绘制类的结构图,尽量详细地解释原理的探索过程
3、提供Github 的 可运行的Demo工程,但是我所提供代码,更多是提供思路,抛砖引玉,请酌情cv
4、集合整理原理探索过程中的一些坑,或者demo的运行过程中的注意事项
5、用gif图,最直观地展示demo运行效果

如果觉得细节太细,直接跳过看结论即可。
本人能力有限,如若发现描述不当之处,欢迎留言批评指正。

学到老活到老,路漫漫其修远兮。与众君共勉 !

引子

产品大佬又提需求啦,要求

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

那么能不能要实现一个全

app
内的一键换肤,一劳永逸~~~

鸣谢

感谢享学课堂的免费视频课程 https://ke.qq.com/course/341933 需要视频的兄弟可以给我留言评论

正文大纲

1. 什么是一键换肤

2. 界面上哪些东西是可以换肤的

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

4. 相关android源码一览

  • Activity 的 setContentView(R.layout.XXX) 到底在做什么?
  • LayoutInflater这个类是怎么把 layout.xml 的 <TextView> 变成TextView对象的?
  • app中资源文件大管家 Resources / AssetManager 是怎么工作的

5. "全app一键换肤" Demo源码详解

  • 关键类 SkinEngine SkinFactory
  • 关键类的调用方式,联系之前的android源码,解释hook起作用的原理
  • 效果展示
  • 注意事项

正文

1. 什么是一键换肤

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

一些换肤实现方式的对比

  • 方案1:自定义View中,要换肤,那如同引言中所述,toggle方法,invalidate重绘。
    弊端:换肤范围仅限于这个View.
  • 方案2:给静态变量赋值,然后重启Activity. 如果一个Activity内用静态变量定义了两种色系,那么确实是可以通过关闭Activity,再启动的方式,实现 貌似换肤的效果(其实是重新启动了Activity)
    弊端:太low,而且很浪费资源

也许还有其他方案吧,

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

 

这个动态图中,首先看到的是

Activity1
,点击换肤,可直接更换界面上的
background
,图片的
src
,还有
textView
textColor
,跳转
Activity2
之后的
textView
颜色,在我换肤之前,和换肤之后,是不同的。换肤的过程我并没有启动另外的
Activity
,界面也没有闪烁。我在
Activity1
里面换肤,直接影响了
Activity2
textView
字体颜色。

既然给出了效果,那么肯定要给出Demo,不然太没诚意,嘿嘿嘿
github地址奉上:https://github.com/18598925736/HookSkinDemoFromHank

2. 界面上哪些东西是可以换肤的

上面的换肤动态图,我换了ImageView,换了background,换了TextView的字体颜色,那么到底哪些东西可以换?

答案其实就一句话: 我们项目代码里面 res目录下的所有东西,几乎都可以被替换。
(为什么说几乎?因为一些犄角旮旯的东西我没有时间一个一个去试验....囧)

具体而言就是如下这些

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

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

  • 什么是hook
    如题,我是用hook实现一键换肤。那么什么是hook?
    hook,钩子. 安卓中的hook技术,其实是一个抽象概念:对系统源码的代码逻辑进行"劫持",插入自己的逻辑,然后放行。注意:hook可能频繁使用java反射机制···

"一键换肤"中的hook思路

  1. "劫持"系统创建View的过程,我们自己来创建View
    系统原本自己存在创建View的逻辑,我们要了解这部分代码,以便为我所用.
  2. 收集我们需要换肤的View(用自定义view属性来标记一个view是否支持一键换肤),保存到变量中
    劫持了 系统创建view的逻辑之后,我们要把支持换肤的这些view保存起来
  3. 加载外部资源包,调用接口进行换肤
    外部资源包,是
    .apk
    后缀的一个文件,是通过
    gradle
    打包形成的。里面包含需要换肤的资源文件,但是必须保证,要换的资源文件,和原工程里面的文件名
    完全相同
    .

4. 相关android源码一览

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

如果不先来点干货,估计有些人就看不下去了,各位客官请看下图:

 

 

源码索引:

setContentView(R.layout.activity_main);

---》
getDelegate().setContentView(layoutResID);

OK,这里暴露出了两个方法,

getDelegate()
setContentView()

先看

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

插曲:关于如何阅读源码?在我的上一篇文章 中详细说明了。
但是漏了一个细节:那就是,当你在源码中看到一个

接口
或者
抽象类
,你想知道接口的
实现类
在哪?很简单...如果你没有更改
androidStudio
的快捷键设置的话,
Ctrl+T
可以帮你直接定位
接口和抽象类的实现类
.

用上面的方法,找到setContentView的具体过程

 

 

那么就进入下一个环节:

LayoutInflater
又做了什么?

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

    我们知道,我们传入的是
    int
    ,是
    xxx.xml
    这个布局文件,在R文件里面的对应int值。
    LayoutInflater
    拿到了这个
    int
    之后,又干了什么事呢?

一路索引进去:会发现这个方法:

 

 

 

 

 

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

[code] 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();
}

if (name.equals(TAG_1995)) {
// Let's party like it's 1995!
return new BlinkLayout(context, attrs);
}

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

if (view == null) {
final Object lastContext = mConstructorArgs[0];
mConstructorArgs[0] = context;
try {
if (-1 == name.indexOf('.')) {
view = onCreateView(parent, name, attrs);
} else {
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;
}
}

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

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

并且在这里,发现一段关键代码:

[code]            if (mFactory2 != null) {
view = mFactory2.onCreateView(parent, name, context, attrs);
} else if (mFactory != null) {
view = mFactory.onCreateView(name, context, attrs);
} else {
view = null;
}

实际上,可能有人要问了,你怎么知道这边是走的哪一个if分支呢?
方法:新创建一个

Project
,跟踪
MainActivity onCreate
里面
setContentView()
一路找到这段代码
debug
:你会发现:

 


答案很明确了,系统在默认情况下就会走Factory2的onCreateView(),
应该有人好奇:这个mFactory2对象是哪来的?是什么时候set进去的
答案如下:

 


如果细心Debug,就会发现
《标记标记,因为后面有一段代码会跳回到这里,这里非常重要...》

 

 

 


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

 

那么继续跟踪,一直到:

AppCompatViewInflater

[code]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;
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

[code]@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
并不满足....具体原因我也没查出来,(;´д`)ゞ

[code]
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的下面这段代码:

[code]            if (mFactory2 != null) {
view = mFactory2.onCreateView(parent, name, context, attrs);
} else if (mFactory != null) {
view = mFactory.onCreateView(name, context, attrs);
} else {
view = null;
}

这段代码的下面,如果view是空,补救措施如下:

[code]            if (view == null) {
final Object lastContext = mConstructorArgs[0];
mConstructorArgs[0] = context;
try {
if (-1 == name.indexOf('.')) {//包含.说明这不是权限定名的类名
view = onCreateView(parent, name, attrs);
} else {//权限定名走这里
view = createView(name, null, attrs);
}
} finally {
mConstructorArgs[0] = lastContext;
}
}

这里的两个方法

onCreateView(parent, name, attrs)
createView(name, null, attrs);
都最终索引到:

[code]public final View createView(String name, String prefix, AttributeSet attrs)
throws ClassNotFoundException, InflateException {
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 = 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);
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;

final View view = constructor.newInstance(args); // 真正需要关注的关键代码,就是这一行,执行了构造函数,返回了一个View对象
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) {
·····
}
}

这么一大段好像有点让人害怕。其实真正需要关注的,就是反射的代码,最后的 newInstance().
OK,Activity上那些丰富多彩的View的来源,就说到这里, 如果有看不懂的,欢迎留言探讨. ( ̄▽ ̄) !

  • app中资源文件大管家
    Resources
    /
    AssetManager
    是怎么工作的

从我们的终极目的出发:我们要做的是“换肤”,如果我们拿到了要换肤的View,可以对他们进行setXXX属性来改变UI,那么属性值从哪里来?
界面元素丰富多彩,但是这些View,都是用资源文件来进行 "装扮"出来的,资源文件大致可以分为:

图片,文字,颜色,声音视频,字体
等。如果我们控制了资源文件,那么是不是有能力对界面元素进行set某某属性来进行“再装扮”呢? 当然,这是可行的。因为,我们平时拿到一个
TextView
,就能对它进行
setTextColor
,这种操作,在
view
还存活的时候,都可以进行操作,并且这种操作,并不会造成
Activity
的重启。
这些资源文件,有一个统一的大管家。可能有人说是R.java文件,它里面统筹了所有的资源文件int值.没错,但是这个R文件是如何产生作用的呢? 答案:Resources.

本来这里应该写上源码追踪记录的,但是由于 源码无法追踪,原因暂时还没找到,之前追查

setContentView(R.layout.xxxx)
的时候还可以
debug
,现在居然不行了,很诡异!

 


答案找到了:因为我使用的是 真机,一般手机厂商都会对原生系统进行修改,然后将系统写到到真机里面。
而,我们
debug
,用的是原生
SDK
。 用实例来说,我本地是
SDK 27
的源码,真机也是
27
的系统,但是真机的运行起来的系统的代码,是被厂家修改了的,和我本地的必然有所差别,所以,有些代码报红,就很正常了,无法
debug
也很正常。

 

既然如此,那我就直接写结论了,一张图说明一切:

 

5. "全app一键换肤" Demo源码详解(戳这里获得源码)

  • 项目工程结构:

     

     

  • 关键类 SkinFactory
    SkinFactory
    类, 继承LayoutInflater.Factory2 ,它的实例,会负责创建View,收集 支持换肤的view
[code]import android.content.Context;
import android.content.res.TypedArray;
import android.support.v7.app.AppCompatDelegate;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.TextView;

import com.enjoy02.skindemo.R;
import com.enjoy02.skindemo.view.ZeroView;

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

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的,所以,我们这次是主要重写Factory的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) {//万一系统创建出来是空,那么我们来补救
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....
int bgId = Integer.parseInt(attrsMap.get("background").substring(1));//属性值,R.id.XXX ,int类型,
// 这个值,在app的一次运行中,不会发生变化
String attrType = view.getResources().getResourceTypeName(bgId); // 属性类别:比如 drawable ,color
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;
}
//**********************************************************************************************

}

关键类 SkinEngine

[code]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.util.Log;

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);
if (outResId == 0) {
return ContextCompat.getDrawable(mContext, resId);
}
return mOutResource.getDrawable(outResId);
}

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

1. 初始化"换肤引擎"

[code]public class MyApp extends Application {

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

2. 劫持 系统创建view的过程

[code]public class BaseActivity extends AppCompatActivity {

...

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

3. 执行换肤操作

[code]protected void changeSkin(String path) {
if (ifAllowChangeSkin) {
File skinFile = new File(Environment.getExternalStorageDirectory(), path);
SkinEngine.getInstance().load(skinFile.getAbsolutePath());//加载外部资源包
mSkinFactory.changeSkin();//执行换肤操作
mCurrentSkin = path;
}
}
  • 效果展示

     

  • 注意事项
    1. 皮肤包skin_plugin module,里面,只提供需要换肤的资源即可,不需要换肤的资源,还有src目录下的源码
    (
    只是删掉java源码文件,不要删目录结构啊....(●´∀`●)
    ),不要放在这里,无端增大皮肤包的体积.
    2. 皮肤包 skin_plugin module的gradle sdk版本最好和app module的保持完全一致,否则无法保证不会出现奇葩问题.
    3. 用皮肤包skin_plugin module 打包生成的apk文件,常规来说,是放在手机内存里面,然后由app module内的代码去加载。至于是手机内存里面的哪个位置,那就见仁见智了. 我是使用的mumu模拟器,我放在了最外层的根目录下面,然后读取这个位置的代码是:
    File skinFile = new File(Environment.getExternalStorageDirectory(), "skin.apk");

     


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

     

    不然切换没有效果.

结语

hook技术是安卓高级层次的技能,学起来并不简单,demo里面的注释我自认为写的很清楚了,如果还有不懂的,欢迎留言评论。读源码也并不是这么轻松的事,可是还是那句话,太简单的东西,不值钱,有高难度才有高回报。为了百万年薪,fighting!

 

 

+qq群:853967238。获取以上高清技术思维图,以及相关技术的免费视频学习资料。

内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: