准备阶段
- 皮肤包是什么样的文件?
- 动态换肤的思想是什么?
皮肤包是什么样的文件?
我们通过解析网易云音乐的皮肤包来理解
- 通过模拟器下载网易云音乐并更换皮肤。
- 在设备/data/data/com.netease.cloudmusic/files/theme目录下可以找到我们的皮肤包并cp到电脑上。
- 修改文件格式为zip,并解压。
我们可以看到,他的文件内容和我们平时apk的内容格式完全一致,那这样后续我们也可以同样方法来制作皮肤包。
动态换肤的方案是什么?- 缓存需要换肤的view,然后设置新样式
所有我们先要了解view的创建,下面我们从sdk源码中寻找答案,这里只看主要流程,不看其他 - 基于sdk版本30。
- Activity
public void setContentView(@LayoutRes int layoutResID) {
// 调用window的setContentView
getWindow().setContentView(layoutResID);
initWindowDecorActionBar();
}
- Window - PhoneWindow
@Override
public void setContentView(int layoutResID) {
// 调用 LayoutInflater的inflate
mLayoutInflater.inflate(layoutResID, mContentParent);
}
- LayoutInflater
@Override
public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
// Temp is the root view that was found in the xml 创建根布局
final View temp = createViewFromTag(root, name, inflaterContext, attrs);
// Inflate all children under temp against its context. 创建子布局 最后也是调用createViewFromTag
rInflateChildren(parser, temp, attrs, true);
}
void rInflate(XmlPullParser parser, View parent, Context context, AttributeSet attrs, boolean finishInflate) throws XmlPullParserException, IOException {
// 循环调用createViewFromTag创建子布局
while (((type = parser.next()) != XmlPullParser.END_TAG ||parser.getDepth() >depth) && type != XmlPullParser.END_DOCUMENT) {
final View view = createViewFromTag(parent, name, context, attrs);
}
}
// 从这个方法中我们看到 尝试通过各种Factory来创建View
public final View tryCreateView(@Nullable View parent, @NonNull String name,@NonNull Context context,@NonNull AttributeSet 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;
}
View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,boolean ignoreThemeAttr) {
// 这里是比较重要的地方
// 尝试通过Factory来创建View
View view = tryCreateView(parent, name, context, attrs);
// 如果没有Factory来创建,那么就调用下面方法创建View
if (view == null) {
if (-1 == name.indexOf('.')) {
// 系统提供的View 不带.的 比如View ,ImageView,TextView
view = onCreateView(context, parent, name, attrs);
} else {
// 第三方View或者自定义view 比如com.cbb.xxxView
view = createView(context, name, null, attrs);
}
}
}
// 看到这里应该比较疑惑 为什么这里只传了android.view. ,很多view明明都不在这个包下
protected View onCreateView(String name, AttributeSet attrs) throws ClassNotFoundException {
// 最终还是调用createView , 传入了系统view的全名
return createView(name, "android.view.", attrs);
}
public final View createView(@NonNull Context viewContext, @NonNull String name,@Nullable String prefix, @Nullable AttributeSet attrs)throws ClassNotFoundException, InflateException {
// 缓存中获取View的构造方法
Constructor<? extends View> constructor = sConstructorMap.get(name);
// 没有缓存则反射获得View的构造方法 并缓存
// 需要注意的是 这里使用的view两个参数的构造方法
if (constructor == null) {
clazz = Class.forName(prefix != null ? (prefix + name) : name, false,
mContext.getClassLoader()).asSubclass(View.class);
constructor = clazz.getConstructor(mConstructorSignature);
constructor.setAccessible(true);
sConstructorMap.put(name, constructor);
}
// 使用构造方法创建view
final View view = constructor.newInstance(args);
}
// sdk提供了设置Factory 的方法
public void setFactory2(Factory2 factory) {
// 这里需要注意mFactorySet 会被如果设置过会被设为true,所以后面我们在设置Factory前需要将其置为false
if (mFactorySet) {
throw new IllegalStateException("A factory has already been set on this LayoutInflater");
}
mFactorySet = true;
}
上述流程分析,我们了解到我们在setContentView开始到创建出view的过程,我们可看到系统在创建view之前会尝试用Factory来创建view,那么我们也可以通过设置自定义Factory来代替系统自带的创建。
上面分析中有个疑问,为什么这里只传了android.view. ,很多view明明都不在这个包下却可以成功创建?这里简单分析一下这个过程
public static LayoutInflater from(Context context) {
LayoutInflater LayoutInflater =
(LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
return LayoutInflater;
}
上面是LayoutInflater的实例化,我们看到实际返回的是context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
我们从源码中追溯上去
- 传入的为Activity的context ,Activity的getSystemService(String name)
- ContextThemeWrapper的getSystemService(String name)
- ContextWrapper中getBaseContext().getSystemService(name)
- getBaseContext()返回的mBase
- ContextWrapper中的attachBaseContext(Context base)赋值
- Activity中的attachBaseContext(context);
- Activity中的attach()方法中attachBaseContext(context)
- 在ActivityThread中performLaunchActivity方法中调用Activity的attach()方法
- 在ActivityThread中performLaunchActivity方法中ContextImpl appContext = createBaseContextForActivity(r)实例化了Context
- ContextImpl中getSystemService(String name)调用SystemServiceRegistry.getSystemService(this, name)返回;
- 拿着LAYOUT_INFLATER_SERVICE去SystemServiceRegistry中寻找发现返回的是PhoneLayoutInflater类
经过上述步骤我们看到了实际返回的是PhoneLayoutInflater类
public class PhoneLayoutInflater extends LayoutInflater {
private static final String[] sClassPrefixList = {
"android.widget.",
"android.webkit.",
"android.app."
};
@Override protected View onCreateView(String name, AttributeSet attrs) throws ClassNotFoundException {
for (String prefix : sClassPrefixList) {
View view = createView(name, prefix, attrs);
if (view != null) {
return view;
}
}
return super.onCreateView(name, attrs);
}
}
从上述PhoneLayoutInflater源码中可以看到PhoneLayoutInflater是LayoutInflater的子类,所以实际是拼接的这三个包下的,如果没有则就是原来的view包下。
上述疑问就得到了解决。
开始编码 (只贴出关键类与关键代码)
拦截系统view的创建
public class SkinLayoutFactory implements LayoutInflater.Factory2 {
// 包目录列表
private static final String[] sClassPrefixList = {
"android.widget.",
"android.webkit.",
"android.app.",
"android.view."
};
// view构造方法的两个参数
private static final Class<?>[] mConstructorSignature = new Class[]{
Context.class, AttributeSet.class};
// 用户缓存已经反射获得的构造方法,防止后续同一个类型的view重复反射
private static final HashMap<String, Constructor<? extends View>> sConstructorMap =
new HashMap<String, Constructor<? extends View>>();
@Nullable
@Override
public View onCreateView(@Nullable View parent, @NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
// 创建view
View view = createViewFromTag(context, name, attrs);
Log.e("Skin", "name = " + name + " , view = " + view);
return view;
}
/**
* 创建view
* 通过判断是否包含.来确定是否区分两种view类型
*
* @param name 可能为TextView , 也可能为xxx.xxx.xxxView
*/
private View createViewFromTag(Context context, String name, AttributeSet attrs) {
View view;
if (-1 == name.indexOf('.')) {
view = createViewByPkgList(context, name, attrs);
} else {
view = createView(context, name, attrs);
}
return view;
}
/**
* 通过遍历系统包来尝试创建view,如果上个没有创建成功有异常会被catch,然后继续尝试下一个包名来创建
*
* @param name 可能为TextView
*/
private View createViewByPkgList(Context context, String name, AttributeSet attrs) {
for (String prefix : sClassPrefixList) {
try {
View view = createView(context, prefix + name, attrs);
if (view != null) {
return view;
}
} catch (Exception e) {
e.printStackTrace();
}
}
return null;
}
/**
* 真正的开始创建view
*
* @param name name 格式为xxx.xxx.xxxView
*/
private View createView(Context context, String name, AttributeSet attrs) {
Constructor<? extends View> constructor = sConstructorMap.get(name);
if (null == constructor) {
try {
Class<? extends View> aClass = context.getClassLoader().loadClass(name).asSubclass
(View.class);
constructor = aClass.getConstructor(mConstructorSignature);
sConstructorMap.put(name, constructor);
} catch (Exception e) {
}
}
if (null != constructor) {
try {
return constructor.newInstance(context, attrs);
} catch (Exception e) {
}
}
return null;
}
}
上述代码我们基本都是cp的系统源码,从而实现我们自己来创建view,现在我们要开始设置Factory,利用sdk提供的ActivityLifecycleCallbacks的来实现。
// 系统提供的可以监听整个app activity的生命周期
public class SkinActivityLifecycle implements Application.ActivityLifecycleCallbacks {
@Override
public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) {
// 拿到对应的layoutInflater 创建skinLayoutFactory 并设置进去
setFactory2(activity);
}
/**
* 监听到activity 生命周期设置Factory 来拦截系统的view创建
* 需要注意的地方为 需要将mFactorySet置为false
* 这里有个缺陷 :>28 那么这个属性就不能使用反射来改变了 系统禁止了
* 可以考虑直接反射来修改Factory的值 这个系统没有限制 这里没有实践
*/
private void setFactory2(Activity activity){
LayoutInflater layoutInflater = LayoutInflater.from(activity);
try {
//Android 布局加载器 使用 mFactorySet 标记是否设置过Factory
//如设置过抛出一次
//设置 mFactorySet 标签为false
Field field = LayoutInflater.class.getDeclaredField("mFactorySet");
field.setAccessible(true);
field.setBoolean(layoutInflater, false);
} catch (Exception e) {
e.printStackTrace();
}
SkinLayoutFactory skinLayoutFactory = new SkinLayoutFactory();
LayoutInflaterCompat.setFactory2(layoutInflater, skinLayoutFactory);
}
}
public class SkinManager {
private static SkinManager instance;
private Application application;
private SkinActivityLifecycle skinActivityLifecycle;
public static void init(Application application) {
synchronized (SkinManager.class) {
if (null == instance) {
instance = new SkinManager(application);
}
}
}
public static SkinManager getInstance() {
return instance;
}
private SkinManager(Application application) {
this.application = application;
//注册Activity生命周期回调
skinActivityLifecycle = new SkinActivityLifecycle();
application.registerActivityLifecycleCallbacks(skinActivityLifecycle);
}
}
上述完成后,我们运行后可以输出
image.png
从这个我们可以我们拦截了系统view的创建来由我们自己创建,至于log输出的这个view列表,大家也应该很熟悉就是DecorView的结构,这里就不做赘述,到了这里我们已经完成了view的创建部分。
开始实现换肤
- 筛选需要的view
上述我们已经拦截了所有的view,实际换肤只需要将需要换肤的view缓存下来就可以了,这里我们通过view的属性来筛选view。
// 只需要设置了这些属性的view
public class SkinAttribute {
static {
mAttributes.add("background");
mAttributes.add("src");
mAttributes.add("textColor");
mAttributes.add("drawableLeft");
mAttributes.add("drawableTop");
mAttributes.add("drawableRight");
mAttributes.add("drawableBottom");
}
// 筛选view
public void load(View view, AttributeSet attrs) {
// 这个view 设置的可以被替换的属性列表
List<SkinPair> skinPairs = new ArrayList<>();
for (int i = 0; i < attrs.getAttributeCount(); i++) {
//获得属性名
String attributeName = attrs.getAttributeName(i);
//是否符合 需要筛选的属性名
if (mAttributes.contains(attributeName)) {
String attributeValue = attrs.getAttributeValue(i);
// 如果不是通过@符号引用的都不管了 比如?护着#之类的 - 实际?也是可能需要换的,这里为了方便
if (!attributeValue.startsWith("@")) {
continue;
}
//资源id
int resId = Integer.parseInt(attributeValue.substring(1));
if (resId != 0) {
//可以被替换的属性
SkinPair skinPair = new SkinPair(attributeName, resId);
skinPairs.add(skinPair);
}
}
}
// 上述已经将这个view需要修改的属性保存进skinPairs了
// 判断skinPairs是否为空 ,不为空就将这个view以后属性信息缓存起来
if (!skinPairs.isEmpty() || view instanceof TextView) {
SkinView skinView = new SkinView(view, skinPairs);
// 去修改样式
skinView.applySkin();
mSkinViews.add(skinView);
}
}
/**
* 遍历view设置样式
*/
public void applySkin() {
for (SkinView mSkinView : mSkinViews) {
mSkinView.applySkin();
}
}
// 需要换肤的view和和属性
static class SkinView {
View view;
List<SkinPair> skinPairs;
public SkinView(View view, List<SkinPair> skinPairs) {
this.view = view;
this.skinPairs = skinPairs;
}
// 设置样式 这里都是在皮肤包里面寻找 如果找不到 返回的就是默认的
public void applySkin() {
for (SkinPair skinPair : skinPairs) {
Drawable left = null, top = null, right = null, bottom = null;
switch (skinPair.attributeName) {
case "background":
Object background = SkinResources.getInstance().getBackground(skinPair
.resId);
//Color
if (background instanceof Integer) {
view.setBackgroundColor((Integer) background);
} else {
ViewCompat.setBackground(view, (Drawable) background);
}
break;
case "src":
background = SkinResources.getInstance().getBackground(skinPair
.resId);
if (background instanceof Integer) {
((ImageView) view).setImageDrawable(new ColorDrawable((Integer)
background));
} else {
((ImageView) view).setImageDrawable((Drawable) background);
}
break;
case "textColor":
((TextView) view).setTextColor(SkinResources.getInstance().getColorStateList
(skinPair.resId));
break;
case "drawableLeft":
left = SkinResources.getInstance().getDrawable(skinPair.resId);
break;
case "drawableTop":
top = SkinResources.getInstance().getDrawable(skinPair.resId);
break;
case "drawableRight":
right = SkinResources.getInstance().getDrawable(skinPair.resId);
break;
case "drawableBottom":
bottom = SkinResources.getInstance().getDrawable(skinPair.resId);
break;
default:
break;
}
if (null != left || null != right || null != top || null != bottom) {
((TextView) view).setCompoundDrawablesWithIntrinsicBounds(left, top, right,
bottom);
}
}
}
}
// 用于保存属性名称和id
static class SkinPair {
String attributeName;
int resId;
public SkinPair(String attributeName, int resId) {
this.attributeName = attributeName;
this.resId = resId;
}
}
}
下面我们在创建view的地方进行筛选并缓存
public class SkinLayoutFactory implements LayoutInflater.Factory2, Observer {
public View onCreateView(@Nullable View parent, @NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
// 创建view
View view = createViewFromTag(context, name, attrs);
Log.e("Skin", "name = " + name + " , view = " + view);
//筛选符合属性的View
skinAttribute.load(view, attrs);
return view;
}
}
到这里,我们创建view的时候通过SkinAttribute类我们可以筛选出可能需要更换皮肤的view,然后保存了每个view和其属性的对关系,我们需要替换的时候就遍历缓存的view然后重新设置的对应属性的。
- 制作皮肤包
- 新建一个Android project/module
- 将需要替换的颜色或者图片拷贝的项目中,需注意和原来项目的中的名称要一致。
- 所有的都替换完成后,直接rebuild,拷贝出生成的apk包
- 可以将名称改为任何你想要的,比如这里我修改为了theme.skin,这就是皮肤包了
- 将其拷贝近手机文件下 - 实际应用中应该网络下载之类的
- 加载皮肤包
public class SkinManager extends Observable {
/**
* 使用皮肤包
*
* @param path 皮肤包地址
*/
public void loadSkin(String path) {
if (TextUtils.isEmpty(path)) {
// 传入空 用默认的
SkinPreference.getInstance().setSkin("");
SkinResources.getInstance().reset();
} else {
try {
AssetManager assetManager = AssetManager.class.newInstance();
// 添加资源进入资源管理器
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String
.class);
addAssetPath.setAccessible(true);
addAssetPath.invoke(assetManager, path);
// 系统resources
Resources resources = application.getResources();
// 外部资源 sResource
Resources sResource= new Resources(assetManager, resources.getDisplayMetrics(),
resources.getConfiguration());
//获取外部Apk(皮肤包) 包名
PackageManager mPm = application.getPackageManager();
PackageInfo info = mPm.getPackageArchiveInfo(path, PackageManager
.GET_ACTIVITIES);
String packageName = info.packageName;
// 皮肤包资源传入工具类SkinResources中方便后续查找
SkinResources.getInstance().applySkin(sResource, packageName);
//保存当前使用的皮肤包
SkinPreference.getInstance().setSkin(path);
} catch (Exception e) {
e.printStackTrace();
}
}
//通知观察者
setChanged();
notifyObservers();
}
}
上面代码将theme.skin加载了,SkinResources是一个工具类,这里传入了传入了外部皮肤包的Resources,举例作用如下
// 根据本app中的资源id寻找皮肤包中的资源id
public int getIdentifier(int resId) {
if (isDefaultSkin) {
return resId;
}
//在皮肤包中不一定就是 当前程序的 id
//获取对应id 在当前的名称 colorPrimary
// 所以要先获取当前名称和类型 再去皮肤包中查找对应的id
String resName = mAppResources.getResourceEntryName(resId);
String resType = mAppResources.getResourceTypeName(resId);
int skinId = mSkinResources.getIdentifier(resName, resType, mSkinPkgName);
return skinId;
}
// 根据资源id获得颜色
public int getColor(int resId) {
// 如果显示默认皮肤 就返回默认的
if (isDefaultSkin) {
return mAppResources.getColor(resId);
}
// 获得在皮肤包的资源id 两个包中的统一名称资源可能id不一样
int skinId = getIdentifier(resId);
if (skinId == 0) {
// 返回皮肤包中的资源
return mAppResources.getColor(resId);
}
return mSkinResources.getColor(skinId);
}
这里加载完皮肤包后,我们需要做的就是通知到view去更新,这里代码就不贴出来了
- 已经有的页面通知SkinAttribute调用applySkin()去遍历已经缓存的view去设置
- 后续打开的页面,包括退出重新进入app,那么就要在SkinLayoutFactory调用onCreateView创建view的时候调用SkinAttribute的load方法去设置
原图2
替换过后... 替换1 替换2
这里已经成功的实现了换肤,退出后重新进入也是显示设置皮肤,但是很多地方也许不够完善,这里只是阐明一个方法,而且关于字体和状态栏都没有替换,后续将继续去替换字体和状态栏部分。
网友评论