简言:
工作中我们经常会收到产品经理提出手机需要有更换皮肤的功能,现在市面上大多数都是以主题换肤为主的,今天我这里介绍另一种,利用hook技术来实现一键换肤功能。
hook简介:
Hook翻译过来是钩子的意思,我们都知道无论是手机还是电脑运行的时候都依赖系统各种各样的API,当某些API不能满足我们的要求时,我们就得去修改某些api,使之能满足我们的要求。这样api hook就自然而然的出现了。我们可以通过api hook,改变一个系统api的原有功能。基本的方法就是通过hook“接触”到需要修改的api函数入口点,改变它的地址指向新的自定义的函数。当然这种技术同样适用于Android系统,在Android开发中,我们同样能利用Hook的原理让系统某些方法运行时调用的是我们定义的方法,从而满足我们的要求。
哪些资源可以替换呢?
我们可以替换的资源有动画、背景图片、字体、字体颜色、字体大小、音频、视频等,总的来说,res目录下的所有资源都可以被替换掉。
换肤的步骤
Android是如何实例化布局的呢?
详细的流程这篇文章已经写的很详细了Android布局加载流程 这里就不做多的探讨了。下面直接到我们的主题。
实现一键换肤
第一步:创建一个SkinFactory类让它继承LayoutInflater.Factory2,该类的功能是:1、拦截系统创建view的过程,由我们自己创建view;2、收集需要换肤的view;3、换肤操作。下面贴出源码:
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) {//万一系统创建出来是空,那么我们来补救
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();
Log.e("changeSkin", "skinView = " + view.getId());
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")) {
Log.e("changeSkin", view.getId() + "");
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 EmptyLayout) {
// //那么这样一个对象,要换肤,就要写针对性的方法了,每一个控件需要用什么样的方式去换,尤其是那种,自定义的属性,怎么去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类,用来做初始化外部资源管理器和内部资源管理器,换肤时,通过两个资源管理器配合获取color值或者drawable下的值。源码如下:
public class SkinEngine {
//单例
@SuppressLint("StaticFieldLeak")
private final static SkinEngine INSTANCE = new SkinEngine();
public static SkinEngine getInstance() {
return INSTANCE;
}
private SkinEngine() {
}
public void init(Context context) {
mContext = context.getApplicationContext();
mInnerResource = mContext.getResources();
//使用application的目的是,如果万一传进来的是Activity对象
//那么它被静态对象instance所持有,这个Activity就无法释放了
}
private Resources mOutResource;// TODO: 外部资源管理器
private Resources mInnerResource;// 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, file.getAbsolutePath());//反射执行方法
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;
}
Log.e("changeSkin", "resId=" + resId + "mOutResource = " + mOutResource.toString() + "mOutPkgName=" + mOutPkgName);
String resName = mInnerResource.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 = mInnerResource.getResourceEntryName(resId);
int outResId = mOutResource.getIdentifier(resName, "drawable", mOutPkgName);
if (outResId == 0) {
return ContextCompat.getDrawable(mContext, resId);
}
return mOutResource.getDrawable(outResId);
}
//..... 这里还可以提供外部资源包里的String,font等等等,只不过要手动写代码来实现getXX方法
}
第三步:在style文件床架自定义属性:
<declare-styleable name="Skinable">
<!--TODO: isSupport=true标识当前控件支持换肤-->
<attr name="isSupport" format="boolean" />
</declare-styleable>
第四步:在Application的oncreate方法下初始化我们的框架:
public class MyApplication extends Application {
@Override
public void onCreate() {
super.onCreate();
SkinEngine.getInstance().init(this);//初始化换肤
}
}
第五步:创建我们的换肤插件
下面是我工程项目的目录
第六步:把我们skin下的res里面的内容全部替换为app目录下的res下的文件全部内容
第七步:编译出apk文件,并且把它命名为:skin2.apk(这个是跟我们app资源是一样的)
然后我们改一下里面的颜色,让其达到换肤的目的,再重新编译一次,生成的apk文件命名为:skin.apk(这是更换了颜色的),最后我们复制这两个apk文件到我们的手机中去,我是复制到我手机的根目录下。
第八步:配置我们的MainActivity,注意:这里因为需要从手机内存读取apk文件,所以需要读取的权限。
public class MainActivity extends AppCompatActivity {
protected static String[] skins = new String[]{"skin.apk", "skin2.apk"};
protected static String mCurrentSkin = null;
private SkinFactory mSkinFactory;
private Button btn_change;
// 要申请的权限
private String[] permissions = {Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE};
@Override
protected void onCreate(Bundle savedInstanceState) {
mSkinFactory = new SkinFactory();
mSkinFactory.setDelegate(getDelegate());
LayoutInflater layoutInflater = LayoutInflater.from(this);
Log.d("layoutInflaterTag", layoutInflater.toString());
layoutInflater.setFactory2(mSkinFactory);
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
// 版本判断。当手机系统大于 23 时,才有必要去判断权限是否获取
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
// 检查该权限是否已经获取
int i = ContextCompat.checkSelfPermission(this, permissions[0]);
// 权限是否已经 授权 GRANTED---授权 DINIED---拒绝
if (i != PackageManager.PERMISSION_GRANTED) {
// 如果没有授予该权限,就去提示用户请求
startRequestPermission();
}
}
btn_change = findViewById(R.id.btn_change);
btn_change.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
changeSkin(getPath());
}
});
}
// 开始提交请求权限
private void startRequestPermission() {
ActivityCompat.requestPermissions(this, permissions, 321);
}
// 用户权限 申请 的回调方法
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requestCode == 321) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if (grantResults[0] != PackageManager.PERMISSION_GRANTED) {
// 判断用户是否 点击了不再提醒。(检测该权限是否还可以申请)
boolean b = shouldShowRequestPermissionRationale(permissions[0]);
if (!b) {
// 用户还是想用我的 APP 的
// 提示用户去应用设置界面手动开启权限
startRequestPermission();
} else {
finish();
}
} else {
Toast.makeText(this, "权限获取成功", Toast.LENGTH_SHORT).show();
}
}
}
}
/**
* 等控件创建完成并且可交互之后,再换肤
*/
@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;
}
public void changeSkin(String path) {
File skinFile = new File(Environment.getExternalStorageDirectory(), path);
Log.e("changeSkin", skinFile.getAbsolutePath());
SkinEngine.getInstance().load(skinFile.getAbsolutePath());
mSkinFactory.changeSkin();
mCurrentSkin = path;
}
}
好了到这步就算完成了,下面看一下我项目运行的效果:
最后在这里贴上项目地址:hook一键换肤。