常用的app中,很多都带有了换肤功能,换肤是为了换资源文件,也就是res下边的资源。
ini
<ImageView
android:layout_width="match_parent"
android:layout_height="?actionBarSize"
android:background="@drawable/toolbar" />
<Button
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:onClick="skinSelect"
android:text="选择皮肤"
android:textColor="?colorAccent" />
我们换肤,比如像上边的imageView和Button,主要就是要替换他的背景或者Color,这就需要了解资源的加载流程了。
资源的加载流程最后都会走到xml解析:
less
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();
}
// 递归解析资源
public View inflate(@LayoutRes int resource, @Nullable ViewGroup root, boolean attachToRoot) {
final Resources res = getContext().getResources();
if (DEBUG) {
Log.d(TAG, "INFLATING from resource: "" + res.getResourceName(resource) + "" ("
+ Integer.toHexString(resource) + ")");
}
View view = tryInflatePrecompiled(resource, res, root, attachToRoot);
if (view != null) {
return view;
}
XmlResourceParser parser = res.getLayout(resource);
try {
return inflate(parser, root, attachToRoot);
} finally {
parser.close();
}
}
最终都会走到这个方法里边android.view.LayoutInflater#createViewFromTag(android.view.View, java.lang.String, android.content.Context, android.util.AttributeSet, boolean):
ini
@UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
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();
}
try {
// 这里尝试创建View,我们换肤需要着重关注这里。这是因为tryCreateView利用了mFactory2来创建View,我们可以通过这里来实现自己的逻辑。
View view = tryCreateView(parent, name, context, attrs);
if (view == null) {
final Object lastContext = mConstructorArgs[0];
mConstructorArgs[0] = context;
try {
if (-1 == name.indexOf('.')) {
view = onCreateView(context, parent, name, attrs);
} else {
view = createView(context, name, null, attrs);
}
} finally {
mConstructorArgs[0] = lastContext;
}
}
return view;
} catch (InflateException e) {
throw e;
} catch (ClassNotFoundException e) {
final InflateException ie = new InflateException(
getParserStateDescription(context, attrs)
+ ": Error inflating class " + name, e);
ie.setStackTrace(EMPTY_STACK_TRACE);
throw ie;
} catch (Exception e) {
final InflateException ie = new InflateException(
getParserStateDescription(context, attrs)
+ ": Error inflating class " + name, e);
ie.setStackTrace(EMPTY_STACK_TRACE);
throw ie;
}
}
@UnsupportedAppUsage(trackingBug = 122360734)
@Nullable
public final View tryCreateView(@Nullable View parent, @NonNull String name,
@NonNull Context context,
@NonNull AttributeSet attrs) {
if (name.equals(TAG_1995)) {
// Let's party like it's 1995!
return new BlinkLayout(context, attrs);
}
View view;
// 重点关注mFactory2。
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;
}
mFactory2是何时创建的呢?在Activity的onCreate的时候,会调用这个方法:
java
@Override
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");
}
}
}
之后系统在创建View的时候就会走这段代码了。
less
@Override
public View createView(View parent, final String name, @NonNull Context context,
@NonNull AttributeSet attrs) {
// 省略无关代码。。。。。
return mAppCompatViewInflater.createView(parent, name, context, attrs, inheritContext,
IS_PRE_LOLLIPOP, /* Only read android:theme pre-L (L+ handles this anyway) */
true, /* Read read app:theme as a fallback at all times for legacy reasons */
VectorEnabledTintResources.shouldBeUsed() /* Only tint wrap the context if enabled */
);
}
我们可以看到,最终走到了androidx.appcompat.app.AppCompatViewInflater#createView(android.view.View, java.lang.String, android.content.Context, android.util.AttributeSet, boolean, boolean, boolean, boolean) 这里边会根据不同的View来实现对应的实例化,这也是为什么我们在xml中声明的TextView,会在初始化之后变成 AppCompatTextView。
ini
public final View createView(@Nullable View parent, @NonNull 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;
case "ToggleButton":
view = createToggleButton(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);
backportAccessibilityAttributes(context, view, attrs);
}
return view;
}
既然谷歌可以替换掉TextView为AppCompatTextView,是不是我们也可以实现类似的效果?就是把TextView换成我们的TextView。
less
LayoutInflater.from(this).setFactory2(new LayoutInflater.Factory2() {
@Nullable
@Override
public View onCreateView(@Nullable View parent, @NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
// 完成了TextView被Button替换的效果。
if (TextUtils.equals(name, "TextView")) {
Button button = new Button(context);
button.setText("AAAA");
return button;
}
return null;
}
@Nullable
@Override
public View onCreateView(@NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
return null;
}
});
但是需要注意,Factory2只能设置一次,上边的逻辑需要在onCreate的super之前调用。或者通过反射修改mFactorySet
的值。
dart
public void setFactory2(Factory2 factory) {
// mFactorySet有值就会抛出异常。
if (mFactorySet) {
throw new IllegalStateException("A factory has already been set on this LayoutInflater");
}
if (factory == null) {
throw new NullPointerException("Given factory can not be null");
}
mFactorySet = true;
if (mFactory == null) {
mFactory = mFactory2 = factory;
} else {
mFactory = mFactory2 = new FactoryMerger(factory, factory, mFactory, mFactory2);
}
}
Activity启动过程中资源的加载流程
performLaunchActivity
-->createBaseContextForActivity(r)
--> ContextImpl.createActivityContext()
--> context.setResources
--> createResources
--> ResourceImpl impl = findOrCreateResourcesImplForKeyLocked
--> impl = createResourcesImpl(key)
--> assets = createAssetManager(key);
--> builder.addApkAssets(loadApkAssets(key.mResDir, false)
AssetManager 加载资源-->资源路径-->默认传入的资源路径key.mResDir(app下面的res,我们可以反射改成皮肤包的资源路径----Resources AssetManager皮肤包的)
不能改变原有的资源加载。单独创建一个AssetManager-->专门加载皮肤包的资源,hide的api是可以直接反射的。
换肤的整体思路
(1)知道xml的View怎么解析?
(2)如何拦截系统的创建流程(setFactory2()实现,这样我们就能自己控制对创建的View的操作了);
(3)拦截后怎么做?重写系统创建过程的代码(复制即可);
(4)收集View以及对应的属性(所有的页面都需要);
(5)拿到皮肤包(也就是apk),进行替换;
(6)如何使用?利用插件化技术,只用插件的resource。系统的资源是通过Resource和AssetManager进行的,当然,Resource最终也是走的AssetManager.
我们采用换肤的最终的方式:
--->系统的资源如何加载?Resource、AssetManager
--->通过Hook技术,创建一个AssetManager。不能用同一个,不影响原流程,因为会造成资源ID冲突,专门加载皮肤包的资源。
--->通过反射addAssetPath 放入皮肤包的路径,从而得到 加载皮肤包资源 AssetManager
。
--->通过app的资源ID--->找到app的资源Name--->皮肤包的资源ID(为什么要这么做?因为我们换肤的时候给的资源的名肯定是一样的,但是这两个资源的ID在编译之后,在app和皮肤包里边一般是不同的,所以我们需要通过这种方式来获取到皮肤包的资源ID然后设置给app)
AssetManager 加载资源-->资源路径-->默认传入的资源路径key.mResDir(app下面的res,改成皮肤包的资源路径----Resources AssetManager皮肤包的)
代码实现
首先我们需要自定义一个 SkinLayoutInflaterFactory
来拦截创建View的流程,因为我们需要记录每一个View的属性,以便后续换肤的时候来对属性进行修改。这里之所以还需要实现Observer,是因为我们换肤之后,需要利用观察者模式通知所有的界面进行更新。因此每一个界面都应该注册一个观察者。
kotlin
public class SkinLayoutInflaterFactory implements LayoutInflater.Factory2, Observer {
因此,我们需要定义一个ApplicationActivityLifecycle,用来在Activity创建的时候,将每一个观察者都传递进去,也就是每一个SkinLayoutInflaterFactory,这样每一个界面都相当于是观察者。
java
public class ApplicationActivityLifecycle implements Application.ActivityLifecycleCallbacks {
那么被观察者是谁呢?我们定义一个被观察者,在加载新皮肤之后,会通知所有的观察者,也就是所有的页面,去更新。
scss
public class SkinManager extends Observable {
private volatile static SkinManager instance;
/**
* Activity生命周期回调
*/
private ApplicationActivityLifecycle skinActivityLifecycle;
private Application mContext;
/**
* 初始化 必须在Application中先进行初始化
*/
public static void init(Application application) {
if (instance == null) {
synchronized (SkinManager.class) {
if (instance == null) {
instance = new SkinManager(application);
}
}
}
}
private SkinManager(Application application) {
mContext = application;
// 共享首选项 用于记录当前使用的皮肤
SkinPreference.init(application);
// 资源管理类 用于从 app/皮肤 中加载资源
SkinResources.init(application);
// 注册Activity生命周期,并设置被观察者
skinActivityLifecycle = new ApplicationActivityLifecycle(this);
application.registerActivityLifecycleCallbacks(skinActivityLifecycle);
// 加载上次使用保存的皮肤
loadSkin(SkinPreference.getInstance().getSkin());
}
public static SkinManager getInstance() {
return instance;
}
/**
* 记载皮肤并应用
*
* @param skinPath 皮肤路径 如果为空则使用默认皮肤
*/
public void loadSkin(String skinPath) {
if (TextUtils.isEmpty(skinPath)) {
// 还原默认皮肤
SkinPreference.getInstance().reset();
SkinResources.getInstance().reset();
} else {
try {
// 反射创建AssetManager 与 Resource
AssetManager assetManager = AssetManager.class.newInstance();
// 资源路径设置 目录或压缩包
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
addAssetPath.invoke(assetManager, skinPath);
// 宿主app的 resources;
Resources appResource = mContext.getResources();
// 根据当前的设备显示器信息 与 配置(横竖屏、语言等) 创建Resources
Resources skinResource = new Resources(assetManager, appResource.getDisplayMetrics(), appResource.getConfiguration());
// 获取外部Apk(皮肤包) 包名
PackageManager mPm = mContext.getPackageManager();
PackageInfo info = mPm.getPackageArchiveInfo(skinPath, PackageManager.GET_ACTIVITIES);
String packageName = info.packageName;
SkinResources.getInstance().applySkin(skinResource, packageName);
// 记录路径
SkinPreference.getInstance().setSkin(skinPath);
} catch (Exception e) {
e.printStackTrace();
}
}
// 通知采集的View 更新皮肤
// 被观察者改变 通知所有观察者
setChanged();
notifyObservers(null);
}
}
另外,我们还需要一个类,用来解析所有的View的属性解析,同时也提供更新属性的方式,以便确定我们对哪些属性来进行替换,一般的,我们不需要对所有的属性都进行替换:
csharp
/**
* 这里面放了所有要换肤的view所对应的属性
*/
public class SkinAttribute {
/**
* 只有这些属性,我们才需要进行替换,其余的不用。
*/
private static final List<String> mAttributes = new ArrayList<>();
static {
mAttributes.add("background");
mAttributes.add("src");
mAttributes.add("textColor");
mAttributes.add("drawableLeft");
mAttributes.add("drawableTop");
mAttributes.add("drawableRight");
mAttributes.add("drawableBottom");
}
/**
* 记录换肤需要操作的View与属性信息
*/
private final List<SkinView> mSkinViews = new ArrayList<>();
/**
* 记录下一个VIEW身上哪几个属性需要换肤textColor/src,每一个页面的view都需要收集。
*
* @param view
* @param attrs
*/
public void look(View view, AttributeSet attrs) {
List<SkinPair> mSkinPars = new ArrayList<>();
for (int i = 0; i < attrs.getAttributeCount(); i++) {
//获得属性名 textColor/background
String attributeName = attrs.getAttributeName(i);
if (mAttributes.contains(attributeName)) {
// 获取属性值
String attributeValue = attrs.getAttributeValue(i);
// 比如color 以#开头表示写死的颜色 不可用于换肤
if (attributeValue.startsWith("#")) {
continue;
}
int resId;
// 以 ?开头的表示使用 属性
if (attributeValue.startsWith("?")) {
int attrId = Integer.parseInt(attributeValue.substring(1));
resId = SkinThemeUtils.getResId(view.getContext(), new int[]{attrId})[0];
} else {
// 正常以 @ 开头
resId = Integer.parseInt(attributeValue.substring(1));
}
// 保存属性名以及对应的ID,每一个属性都有对应的ID,为后边映射皮肤包ID做准备。
SkinPair skinPair = new SkinPair(attributeName, resId);
mSkinPars.add(skinPair);
}
}
if (!mSkinPars.isEmpty() || view instanceof SkinViewSupport) {
SkinView skinView = new SkinView(view, mSkinPars);
// 如果选择过皮肤 ,调用 一次 applySkin 加载皮肤的资源
skinView.applySkin();
mSkinViews.add(skinView);
}
}
/*
对所有的view中的所有的属性进行皮肤修改
*/
public void applySkin() {
// 第一层循环,找到所有的View
for (SkinView mSkinView : mSkinViews) {
mSkinView.applySkin();
}
}
static class SkinView {
View view;
//这个View的能被 换肤的属性与它对应的id 集合
List<SkinPair> skinPairs;
public SkinView(View view, List<SkinPair> skinPairs) {
this.view = view;
this.skinPairs = skinPairs;
}
/**
* 对一个View中的所有的属性进行修改
*/
public void applySkin() {
applySkinSupport();
// 第二层循环,找到所有的view对应的属性,然后进行设置,实现真正的效果更改。
for (SkinPair skinPair : skinPairs) {
Drawable left = null, top = null, right = null, bottom = null;
switch (skinPair.attributeName) {
case "background":
// 这一行就实现了:通过app的资源ID--->找到app的资源Name--->皮肤包的资源ID
Object background = SkinResources.getInstance().getBackground(skinPair.resId);
//背景可能是 @color 也可能是 @drawable
if (background instanceof Integer) {
view.setBackgroundColor((int) background);
} else {
ViewCompat.setBackground(view, (Drawable) background);
}
break;
case "src":
// 这一行就实现了:通过app的资源ID--->找到app的资源Name--->皮肤包的资源ID
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);
}
}
}
private void applySkinSupport() {
if (view instanceof SkinViewSupport) {
((SkinViewSupport) view).applySkin();
}
}
}
static class SkinPair {
/**
* 属性名
*/
String attributeName;
/**
* 对应的资源id
*/
int resId;
public SkinPair(String attributeName, int resId) {
this.attributeName = attributeName;
this.resId = resId;
}
}
}
总结
(1)通过自定义 LayoutInflater.Factory2
来实现可控制的View创建过程,让我们能够对所有的创建的View,收集他们需要换肤的属性,以及属性对应的ID;
(2)定义观察者与被观察者,观察者就是每一个页面,被观察者就是换肤类。当换肤类触发了换肤之后,会通过状态分发让所有页面更新,如果还没有启动的Activiyt是否就不能更新了呢?当然不会,因为我们的可以直接调用 SkinManager.getInstance().loadSkin(skinPkg),来实现未启动的页面的更新。
(3)通过app的资源ID--->找到app的资源Name--->皮肤包的资源ID(为什么要这么做?因为我们换肤的时候给的资源的名肯定是一样的,但是这两个资源的ID在编译之后,在app和皮肤包里边一般是不同的,所以我们需要通过这种方式来获取到皮肤包的资源ID然后设置给app),这也是为什么我们在最初解析的时候需要使用下边的代码:
arduino
static class SkinPair {
/**
* 属性名
*/
String attributeName;
/**
* 对应的资源id
*/
int resId;
public SkinPair(String attributeName, int resId) {
this.attributeName = attributeName;
this.resId = resId;
}
}
测试源码:github.com/xingchaozha...
几个问题
1、自定义View的绘制流程:
1、View几个构造方法的区别?
less
public class CustomView extends View {
/**
* 一般在直接new一个view的时候使用
* @param context
*/
public CustomView(Context context) {
super(context);
}
/**
* 一般在layout文件中使用的时候回调用,关于它的属性(包括自定义属性)都会在attrs中传递进来。
* @param context
* @param attrs
*/
public BigView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
// <com.example.MyView
// android:layout_width="wrap_content"
// android:layout_height="wrap_content"
// custom:myAttribute="value" />
//如果在这里这样写就会调用到三个参数的构造方法
//this(context, attrs,0);
}
/**
* @param context
* @param attrs
* @param defStyleAttr 默认的style,指的是当期application或者activity所用的theme中默认的style,
* 且只有明确调用的时候才会生效,
* 如 this(context, attrs, com.android.internal.R.attr.imageButtonStyle);
* 注意:即使在view中使用了style这个属性,也不会调用这个构造方法,所以这个构造方法
* 也不考虑。
*/
public CustomView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
//this(context, attrs, com.android.internal.R.attr.imageButtonStyle);
}
/**
* android5.0以后的api才有
* 如果第三个参数为0或者没有定义defStyleAttr时,第四个参数才起作用,它是style的引用.
* 不同于`defStyleAttr`,`defStyleAttr`是在当前主题中查找样式,
* 而`defStyleRes`直接引用一个明确的样式资源。这个构造方法提供了更多的灵活性来处理视图的样式。
* @param context
* @param attrs
* @param defStyleAttr
* @param defStyleRes
*/
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
public BigView(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
}
3、 我们自定义View的时候,在使用的时候需要加上全路径,为什么系统内置的LinearLayout,Relativelayout这些不用呢?请看这里:
arduino
protected View onCreateView(String name, AttributeSet attrs)
throws ClassNotFoundException {
return createView(name, "android.view.", attrs);
}
因为他们在创建的时候把前缀加上去了。
4、 换肤的过程中,比如Color中直接通过#FF00000,这种方式, 是无法替换的,因为这种只有ID,而没有name。无法映射到皮肤包中。