在《Android换肤体验-------侧滑菜单的实现+换肤的原理》中,简单介绍了一下换肤的基本原理,在APP中存在很多控件,那么在换肤时,不会将所有的控件都换肤,那么如何根据获取到的控件得知是否要换肤。
(1)自定义属性:自定义一个属性值,在控件中如果查找到了这个属性值,那么这个控件就需要换肤;
(2)判断这个控件中是否存在“background
”、“src
”、“textColor
”等类型的属性,那么这个控件就需要换肤。
一、问题:
1.什么时候换肤?
在我们点击换肤按钮的时候,不仅在当前Activity需要换肤,在这个APP中的所有Activity都需要换肤,所以这个换肤的时机要把握好。
在《Android换肤体验-------侧滑菜单的实现+换肤的原理》中介绍过,系统加载布局的内部原理,在setContentView
时其实是内部的AppCompatDelegateImpl
调用setContentView
方法,通过LayoutInflater
调用inflate
方法加载XML布局,在inflate方法中有一个createViewFromTag
,会根据LayoutInflater
当中Factory的接口类型(Factory
or Factory2
)调用CreateView
方法加载,其中通过“name
”可以得到加载的控件Tag,通过AttributeSet
得到控件的全部属性,所以在此进行换肤就是最佳时机。
2.如何快速找到换肤的控件?
每个Activity中都有换肤的控件,如何快速找到这个需要换肤的控件。
同样在上一问当中回答了,就是在加载XML布局,即控件实例化的时候,通过AttributeSet
得到控件的属性,判断该属性是否符合换肤的属性,如果是,那么就将其定义为一个换肤控件SkinView。
3.如何批量对控件进行换肤?
在找到换肤控件后,肯定是大批量的控件,因为Activity众多,控件自然也多,一个一个地换肤,耗时不高效。
在上一问中,当找到要换肤的控件时,会将其定义为一个换肤View,然后把找到的所有换肤View放在一个容器中,当点击换肤按钮时,就将这些控件属性全部换肤。
4.通过什么方式换肤?
这一部分在《Android换肤体验-------侧滑菜单的实现+换肤的原理》中介绍过,有静态换肤和动态换肤两种,即在应用内换肤以及动态加载插件apk资源的换肤。
二、换肤框架的搭建:
**1.采用动态加载技术,获取资源包中的资源对象;**在《Android换肤体验-------侧滑菜单的实现+换肤的原理》中介绍过了动态加载技术。
2.通过资源对象获取资源包中的资源;
在这里再写一遍吧,单独列了一个管理类。
public class SkinManager {
private Context context;
//插件包的资源
private Resources resources;
//插件包的包信息类
private String packageName;
private SkinManager(){}
private static SkinManager instance = new SkinManager();
public static SkinManager getInstance(){
return instance;
}
public void setContext(Context context){
this.context = context;
}
/**
* 加载获取资源包的资源
* @param pluin_path 资源apk的路径
*/
public void getPluginResource(String pluin_path){
try {
//获取插件apk的包名
PackageManager pm =context.getPackageManager();
PackageInfo packageArchiveInfo = pm.getPackageArchiveInfo
(pluin_path, PackageManager.GET_ACTIVITIES);
//资源包的包名
packageName = packageArchiveInfo.packageName;
AssetManager assetManager = AssetManager.class.newInstance();
Method addAssetPath = assetManager.getClass().getDeclaredMethod("addAssetPath", String.class);
addAssetPath.invoke(assetManager,pluin_path);
//获取系统资源
Resources SuperResources = context.getResources();
//获取插件资源
resources = new Resources(assetManager,SuperResources.getDisplayMetrics(),
SuperResources.getConfiguration());
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (InstantiationException e) {
e.printStackTrace();
}catch (NoSuchMethodException e) {
e.printStackTrace();
}catch (InvocationTargetException e) {
e.printStackTrace();
}
}
/**
* 获取资源包apk的颜色资源
* @param id 主APP中的资源id
* @return 资源包中的资源id
*/
public int getColor(int id){
if(resources == null){
return id;
}
//获取当前APP中id的资源类型,如果id是颜色类型,那么返回的就是颜色类型 resourceTypeName:color
String resourceTypeName = context.getResources().getResourceTypeName(id);
//获取到传入的id在资源中的名字 colorPrimary
String resourceEntryName = context.getResources().getResourceEntryName(id);
//根据名字和类型去皮肤资源apk中查找这个对应值 @color/colorPrimary
int identifier = resources.getIdentifier(resourceEntryName, resourceTypeName, packageName);
if(identifier == 0){
return id;
}
return resources.getColor(identifier);
}
public Drawable getDrawable(int id){
if(resources == null){
return ContextCompat.getDrawable(context,id);
}
//获取当前APP中id的资源类型,如果id是drawable类型,那么返回的就是图片类型 resourceTypeName:drawable
String resourceTypeName = context.getResources().getResourceTypeName(id);
//获取到传入的id在资源中的名字 xxx.jpg
String resourceEntryName = context.getResources().getResourceEntryName(id);
//根据名字和类型去皮肤资源apk中查找这个对应值
int identifier = resources.getIdentifier(resourceEntryName, resourceTypeName, packageName);
if(identifier == 0){
return ContextCompat.getDrawable(context,id);
}
return resources.getDrawable(identifier);
}
}
3.收集需要换肤的控件
在之前介绍过,在获取的控件中,如果有background
属性、textColor
属性、src
属性,就是需要换肤的控件。
android:background="@drawable/bg1"
android:textColor="@color/colorPrimary"
因为APP的Activity很多,我们不可能在每一个Activity中,都通过LayoutInflater去加载XML布局,在之前MVP架构中`《MVP架构设计模式1》,我们创建过一个BaseActivity,那么通过在BaseActivity中进行控件捕获,更加高效,因为每个Activity都继承自BaseActivity。
public abstract class BaseActivity<V,P extends BasePresenter<V>> extends AppCompatActivity {
protected P mPresenter;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
//在此做换肤的准备
LayoutInflaterCompat.setFactory2(getLayoutInflater(),new SkinFactory());
mPresenter = createPresenter();
mPresenter.attachView((V) this);
//一定放在后边,不然会先onCreate,在这个当中已经有LayoutInflater加载布局了,会报错!
super.onCreate(savedInstanceState);
}
这样每个继承自BaseActivity的Activity都可以监听到加载XML布局的过程。
public class SkinFactory implements LayoutInflater.Factory2 {
@Nullable
@Override
public View onCreateView(@Nullable View parent, @NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
return null;
}
@Nullable
@Override
public View onCreateView(@NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
return null;
}
}
在onCreateView
方法当中,我们可以根据“name
”来获取当前控件的类型,通过AttributeSet
来遍历该控件的全部属性值,通过属性值中是否存在“background
”、“src
”、“textColor
”等类型的属性,如果存在那么这个控件就需要换肤。
因为我们通过hook截获了系统加载XML实例化控件的过程,因为需要我们自己实现控件的实例化并返回。
2020-02-10 11:01:56.339 32250-32250/? E/TAG: name====TextView
2020-02-10 11:01:56.339 32250-32250/? E/TAG: attrs=====textSize
2020-02-10 11:01:56.339 32250-32250/? E/TAG: attrs=====textColor
2020-02-10 11:01:56.339 32250-32250/? E/TAG: attrs=====gravity
2020-02-10 11:01:56.339 32250-32250/? E/TAG: attrs=====layout_width
2020-02-10 11:01:56.339 32250-32250/? E/TAG: attrs=====layout_height
2020-02-10 11:01:56.339 32250-32250/? E/TAG: attrs=====layout_marginTop
2020-02-10 11:01:56.339 32250-32250/? E/TAG: attrs=====text
我们从打印的控件信息中看出,并没有带包名,不像是自定义控件都带全类名。如果带包名,直接通过反射实例化,如果不带包名,那么该控件一定是下面三个包里面的。
private static final String[] packList =
{"android.widget.","android.view.","android.webkit."};
所以不带包名的系统控件,那就一个一个去匹配,肯定有一个是匹配的。
@Nullable
@Override
public View onCreateView(@Nullable View parent, @NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
//实例化
View view = null;
if(name.contains(".")){
//带包名,反射实例化
view = onCreateView(name,context,attrs);
}else{
//不带包名
for (String s :
packList) {
String viewName = s + "name";
view = onCreateView(viewName,context,attrs);
if(view != null){
//一旦匹配成功,直接break;
break;
}
}
}
//在return View之前去判断是不是换肤View,如果是那么就去换肤
if(view != null){
parseView(view,name,attrs);
}
return view;
}
匹配成功之后,通过反射实例化控件。
@Nullable
@Override
public View onCreateView(@NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
View view = null;
try {
Class aClass = context.getClassLoader().loadClass(name);
//获取View的第二个构造方法,重载
Constructor<? extends View> constructor = aClass.getConstructor(Context.class, AttributeSet.class);
view = constructor.newInstance(context, attrs);
} catch (Exception e) {
e.printStackTrace();
}
return view;
}
}
如此以来,就完成了控件的实例化返回,也就是通过hook截获了系统加载实例化控件的过程。
接下来,就判断该控件是否符合之前说的那样,存在“background
”、“src
”、“textColor
”等类型的属性,如果有,那么就将这些控件收集起来。
在收集View的时候,需要将View放在一个容器里,但是如果将一整个View放在容器中,在换肤的时候,需要遍历所有的属性(包括不需要换肤的属性),这样会非常耗时,所以我们考虑的是,将单个属性封装为一个对象,这样只需要保存这个属性对象即可。
android:textColor="@color/colorPrimary"
在一条属性中,包含这么几个值:属性名textColor
、属性id @color/colorPrimary
、属性类型color
、属性值colorPrimary
,可以构建成属性对象
/**
* background = "@drawable/bg"
*/
public class SkinItem {
//属性名字 textColor background
private String name;
//属性值的类型 color mipmap drawable
private String typeName;
//属性值的名字 colorPrimary
private String entryName;
//属性id @color/colorPrimary
private int id;
public SkinItem(String name, String typeName, String entryName, int id) {
this.name = name;
this.typeName = typeName;
this.entryName = entryName;
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getTypeName() {
return typeName;
}
public void setTypeName(String typeName) {
this.typeName = typeName;
}
public String getEntryName() {
return entryName;
}
public void setEntryName(String entryName) {
this.entryName = entryName;
}
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
}
那么每一个换肤控件就包含其中的换肤属性:
public class SkinView {
private View view;
//一个view控件有多个属性
private List<SkinItem> skinItems;
public SkinView(View view, List<SkinItem> skinItems) {
this.view = view;
this.skinItems = skinItems;
}
}
那么我们寻找换肤的控件容器中,存放的就是SkinView,换肤的控件以及换肤的属性值。
private List<SkinView> viewList = new ArrayList<>();
然后在onCreateView方法中,是循环遍历去初始化View,当获取一个实例化View时,就需要去判断是否需要换肤。
/**
* 判断当前控件是否符合换肤标准,如果符合,就装到容器中
* @param view
* @param name
* @param attrs
*/
private void parseView(View view, String name, AttributeSet attrs) {
List<SkinItem> skinItems = new ArrayList<>();
Log.e("TAG",name);
for (int i = 0; i < attrs.getAttributeCount(); i++) {
//遍历当前所有View的属性 attributeName相当于是一个View的属性集合
//从layoout_width layout_height id textColor.....开始遍历
String attributeName = attrs.getAttributeName(i);
Log.e("TAG","attributeName==="+attributeName);
if(attributeName.contains("textColor") || attributeName.contains("background")
||attributeName.contains("src")){
//如果包含这三个属性其中一个,就是换肤控件
//key-value key:name value:value
int resId = Integer.parseInt(attrs.getAttributeValue(i).substring(1));
//获取属性名 属性值的类型,属性值的名字
String typeName = view.getResources().getResourceTypeName(resId);
String entryName = view.getResources().getResourceEntryName(resId);
SkinItem skinItem = new SkinItem(attributeName,typeName,entryName,resId);
skinItems.add(skinItem);
}
}
if(skinItems.size() >0){
//当前这个换肤View,需要换肤的属性
SkinView skinView = new SkinView(view,skinItems);
viewList.add(skinView);
}
}
4.将皮肤资源包中的资源替换到主APP中。
public class SkinView {
private View view;
private List<SkinItem> skinItems;
public SkinView(View view, List<SkinItem> skinItems) {
this.view = view;
this.skinItems = skinItems;
}
//换肤
public void apply(){
for (SkinItem skin :
skinItems) {
if (skin.getName().equals("background")){
//背景可能使用的是图片,也可能使用的是图片
if(skin.getTypeName().equals("color")){
view.setBackgroundColor(SkinManager.getInstance().getColor(skin.getId()));
}else if(skin.getTypeName().equals("drawable")){
view.setBackground(SkinManager.getInstance().getDrawable(skin.getId()));
}
}else if(skin.getName().equals("textColor")){
if(view instanceof TextView){
((TextView) view).setTextColor(SkinManager.getInstance().getColor(skin.getId()));
}else if(view instanceof Button){
view.setBackgroundColor(SkinManager.getInstance().getColor(skin.getId()));
}
}else if(skin.getName().equals("src")){
}
}
}
}
在SkinView中换肤,每个需要换肤的View,判断换肤的属性,然后设置换肤的资源,最终在实例化View的时候,加载。
public void apply(){
for (SkinView views :
viewList) {
//启动
views.apply();
}
}
三、创建一个资源包
创建一个Module或者Library都可以。
在主APP中,某个TextView的字体颜色为colorPrimary
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="主页"
android:textSize="18sp"
android:textColor="@color/colorPrimary"
android:gravity="center_horizontal"
android:layout_marginTop="2dp"
></TextView>
主APP的颜色资源:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#008577</color>
<color name="colorPrimaryDark">#00574B</color>
<color name="colorAccent">#D81B60</color>
</resources>
那么在换肤的时候,如果发现资源apk中,也有这个颜色,那么就将资源apk中的这个颜色值,替换到主APP的TextView的颜色值。
资源apk的颜色资源:(全部是黑色,当得到资源apk的这个颜色信息colorPrimary
后,会将黑色替换到主app,之前主app是绿色)
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#000000</color>
<color name="colorPrimaryDark">#000000</color>
<color name="colorAccent">#000000</color>
</resources>