在我们使用APP时,几乎每个APP都有换肤功能,而对于不同的皮肤包,有些比较小,可以在应用内实现换肤,而有些比较大的皮肤包,继承到APP中占用内存极大,因此采用插件化的方式实现换肤。本节就从多个方式实现换肤体验。
1.关于侧滑菜单的实现
在使用QQ时,进行侧滑操作时,会进入到个人中心界面,这个就是通过侧滑菜单实现的,那么侧滑菜单的实现,是如何完成的?
(1)使用DrawerLayout和NavigationView实现
导入依赖:
implementation 'com.android.support:design:28.0.0'
implementation 'com.readystatesoftware.systembartint:systembartint:1.0.4'
NavigationView的特性:
在使用NavigationView的时候,尤其是在我们往xml布局添加NavigationView时,我们在将NavigationView放入布局的时候,我们之前设置的布局就不见了,不知你们是否有相似的体验。
这也是NavigationView的一个很重要的特性,尤其是在我们打开或者关闭侧滑菜单时,使用到的一个属性。NavigationView可以在侧拉菜单中添加头文件和Menu菜单,可以根据我们自己的设置往里添加。
主界面的布局:标题栏+ListView(LinearLayout)+侧拉菜单
<androidx.drawerlayout.widget.DrawerLayout 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:id="@+id/drawerlayout"
tools:context=".view.activity.MainActivity">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<include layout="@layout/title"></include>
<ListView
android:id="@+id/lv_main"
android:layout_width="match_parent"
android:layout_height="match_parent"
></ListView>
</LinearLayout>
<com.google.android.material.navigation.NavigationView
android:id="@+id/nv_left"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="left"
app:headerLayout="@layout/header"
app:menu="@menu/menu_left"
></com.google.android.material.navigation.NavigationView>
</androidx.drawerlayout.widget.DrawerLayout>
一定要设置android:layout_gravity="left"
这个属性,不然侧拉菜单会覆盖整个主界面,这样实现布局之后,基本就可以手动滑动侧拉菜单了。
在侧拉菜单中,我们可以设置Menu布局,添加想要的item。
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<group android:id="@+id/group1" android:checkableBehavior="single">
<item android:id="@+id/itme1"
android:icon="@mipmap/emoji6"
android:title="应用内换肤(Red)"></item>
<item android:id="@+id/itme2"
android:icon="@mipmap/emoji6"
android:title="应用内换肤(Green)"></item>
<item android:id="@+id/itme3"
android:icon="@mipmap/emoji6"
android:title="第三个Item"></item>
<item android:id="@+id/itme4"
android:icon="@mipmap/emoji6"
android:title="插件式换肤1"></item>
<item android:id="@+id/itme5"
android:icon="@mipmap/emoji6"
android:title="恢复默认"></item>
</group>
</menu>
可以设置单击事件,响应每个Item。
nv_left.setNavigationItemSelectedListener(new NavigationView.OnNavigationItemSelectedListener() {
@Override
public boolean onNavigationItemSelected(@NonNull MenuItem menuItem) {
switch (menuItem.getItemId()){
case R.id.itme1:
Toast.makeText(MainActivity.this,"单击了第一个Item",Toast.LENGTH_SHORT).show();
break;
case R.id.itme2:
Toast.makeText(MainActivity.this,"单击了第二个Item",Toast.LENGTH_SHORT).show();
break;
case R.id.itme3:
Toast.makeText(MainActivity.this,"单击了第三个Item",Toast.LENGTH_SHORT).show();
break;
case R.id.itme4:
Toast.makeText(MainActivity.this,"单击了第四个Item",Toast.LENGTH_SHORT).show();
break;
case R.id.itme5:
Toast.makeText(MainActivity.this,"单击了第五个Item",Toast.LENGTH_SHORT).show();
break;
}
//点击Item后退出侧滑菜单
drawerlayout.closeDrawer(GravityCompat.START);
return true;
}
});
然后实现主界面的布局ListView,很简单就不在这里写了。
2.插件化更新皮肤
将.apk文件作为皮肤包,放在手机的SD卡中,从SD卡中获取想要的资源。
将.apk文件加载到SD卡中之后,就去读取插件中的资源文件。在我们去获取系统资源的时候,我们通过getResource()
方法去获取,但是如果想去获取插件中的资源,我们使用getResource()
是不可能获取到的,因为getResource()
默认的资源地址是宿主APK的资源,那么我们只能去通过反射修改AssetManager的加载路径,通过getResource()
来获取插件apk的资源,这个思想在插件化中是同样的思想。
在获取插件apk中的资源需要几个参数:插件apk的地址(保存在sd卡中)、插件apk的包名、AssetManager对象。
/**
* 加载插件资源
* @param plugin_path 插件的位置
* @param plugin_packagename 插件包名
*/
private void loadPlugin(String plugin_path, String plugin_packagename) {
//创建AssetManager
try {
AssetManager manager = AssetManager.class.newInstance();
Method addAssetPath = manager.getClass().getDeclaredMethod("addAssetPath", String.class);
//传入插件的路径
addAssetPath.invoke(manager,plugin_path);
//获取系统Resource
Resources superResources = getResources();
//通过AssetManager得到了指定路径的资源
Resources resources = new Resources(manager,superResources.getDisplayMetrics(),
superResources.getConfiguration());
} catch (NoSuchMethodException e) {
e.printStackTrace();
}catch (IllegalAccessException e) {
e.printStackTrace();
} catch (InstantiationException e) {
e.printStackTrace();
}catch (InvocationTargetException e) {
e.printStackTrace();
}
}
得到这个resources
对象,就是插件apk的资源对象,而不是宿主APK的资源对象,通过这个资源对象以及包名,就可以获取插件apk的资源。
public class ResourceManager {
//插件资源
private Resources mResources;
//插件apk的包名
private String mPackageName;
public ResourceManager(Resources mResources, String mPackageName) {
this.mResources = mResources;
this.mPackageName = mPackageName;
}
public Drawable getDrawableByName(String name){
try {
return mResources.getDrawable(mResources.getIdentifier(name, "drawable", mPackageName));
}catch (Exception e){
e.getStackTrace();
return null;
}
}
}
通过定义的资源管理类,得到插件apk中的资源
//获取资源
ResourceManager resourceManager = new ResourceManager(resources,plugin_packagename);
Drawable bg1 = resourceManager.getDrawableByName("bg1");
Log.e("TAG","bg1==="+bg1);
if(bg1 != null){
drawerlayout.setBackground(bg1);
}else{
Toast.makeText(MainActivity.this,"更换背景失败",Toast.LENGTH_SHORT).show();
}
3.捕获需要换肤的控件
在创建Activity的时候,会调用onCreate方法,初始化加载我们的布局,通过setContentView加载我们的布局。
@Override
public void setContentView(@LayoutRes int layoutResID) {
getDelegate().setContentView(layoutResID);
}
通过代码可以看到,是通过getDelegate()
方法调用了setContentView
方法,getDelegate
得到的是一个Delegate
的实现类AppCompatDelegateImpl
,是这个类调用了setContentView
方法。
@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();
}
其实setContentview
方法调用的是这个方法,当中有一个LayoutInflater
类,这个我们经常使用,就是加载布局的类,通常使用inflate
方法加载布局。
public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
final String name = parser.getName();
final View temp = createViewFromTag(root, name, inflaterContext, attrs);
return temp;
}
真正生成布局的方法就是 createViewFromTag
,根据Tag来生成View,Tag是什么?就是我们在XML布局文件中,添加控件时的第一个单词,例如“TextView”、“Button”、“EditText”等等;
在 createViewFromTag
当中,会判断,LayoutInflater
中接口的类型是Factory
还是Factory2
,现在一般都是Factory2
。
View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
boolean ignoreThemeAttr) {
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;
}
return view;
} catch (Exception e) {
}
}
如上图所示,通过设置setFactory
调用onCreateView
,加载布局。如果没有使用LayoutInflater
设置Factory
加载布局,那么使用LayoutInflaterCompat
加载布局。
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");
}
}
}
例如下载一段代码,在布局中,添加的是一个TextView,那么我们采用hook技术,截获系统加载布局的代码,让其返回一个EditText控件。
LayoutInflater inflater = (LayoutInflater) getSystemService(Context.LAYOUT_INFLATER_SERVICE);
// inflater.setFactory();
LayoutInflaterCompat.setFactory(inflater, new LayoutInflaterFactory() {
@Override
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
for (int i = 0; i < attrs.getAttributeCount(); i++) {
String attributeName = attrs.getAttributeName(i);
String attributeValue = attrs.getAttributeValue(i);
Log.e("TAG",attributeName+"=="+attributeValue);
}
if(name.equals("TextView")){
//如果得到的Tag是TextView,那么就返回创建一个TextView
return new EditText(context,attrs);
}
return null;
}
});
因此,通过这个方法,可以捕获整体布局中的全部控件,哪个控件换肤,我们可以得到该控件的属性,做修改。
那么怎么知道这个控件哪些属性需要换肤?我们可以为控件设置属性文件,以某个字母开头,比如“skin”,那么获取以这个单词名开头的属性,就是需要换肤的。在插件apk中存在我们的主题资源,在主APP中,假设某个控件它的background
属性值是一个颜色值@Color/Primary
,假设在插件apk中有这个资源名称与之对应,那么就更新这个控件的背景。
好了,换肤的底层原理就先讲到这里,之后会更新一下主题换肤的框架。