一、夜间模式的实现方式
夜间模式的实现方式有两种,一种是本地替换,一种是用动态换肤的方案替换。
但是两中方案的思想大致是一样的,就是收集页面View,在页面初始化时替换为自定义的相关view,在自定义view实现的接口方法里进行日夜间的判断及不同资源的加载。
二、具体的实现
/**
* 换肤Activity父类
*
* 用法:
* 1、继承此类
* 2、重写openChangeSkin()方法
*/
public class SkinActivity extends AppCompatActivity {
private CustomAppCompatViewInflater viewInflater;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
LayoutInflater layoutInflater = LayoutInflater.from(this);
LayoutInflaterCompat.setFactory2(layoutInflater, this);
super.onCreate(savedInstanceState);
}
// TODO 第三大步骤,收集布局里面所有的View
@Override
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
if (openChangeSkin()) {
if (viewInflater == null) {
viewInflater = new CustomAppCompatViewInflater(context);
}
viewInflater.setName(name);
viewInflater.setAttrs(attrs);
return viewInflater.autoMatch(); // TODO 第三大步:第二小步
}
return super.onCreateView(parent, name, context, attrs);
}
/**
* @return 是否开启换肤,增加此开关是为了避免开发者误继承此父类,导致未知bug
*/
protected boolean openChangeSkin() {
return false;
}
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
protected void defaultSkin(int themeColorId) {
this.skinDynamic(null, themeColorId);
}
/**
* 动态换肤(api限制:5.0版本)
*/
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
protected void skinDynamic(String skinPath, int themeColorId) {
// TODO 第一大步:收集皮肤包里面的所有信息
SkinManager.getInstance().loaderSkinResources(skinPath);
// TODO 第二大步:调用了系统的 进行改变
if (themeColorId != 0) {
// ActionBus 状态栏 + 底部栏
int themeColor = SkinManager.getInstance().getColor(themeColorId);
StatusBarUtils.forStatusBar(this, themeColor);
NavigationUtils.forNavigation(this, themeColor);
ActionBarUtils.forActionBar(this, themeColor);
}
// TODO 第四大步骤:真正的换肤
applyViews(getWindow().getDecorView());
}
/**
* 控件回调监听,匹配上则给控件执行换肤方法
*/
protected void applyViews(View view) {
if (view instanceof ViewsMatch) {
ViewsMatch viewsMatch = (ViewsMatch) view;
viewsMatch.skinnableView();
}
if (view instanceof ViewGroup) {
ViewGroup parent = (ViewGroup) view;
int childCount = parent.getChildCount();
for (int i = 0; i < childCount; i++) {
applyViews(parent.getChildAt(i));
}
}
}
}
这就是所需换肤的Activity父类,所有需要换肤的Activity需要继承自此Activity。
- 首先注意到我重写了
onCreate
方法,并在其中设置了setFactory2
。
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
LayoutInflater layoutInflater = LayoutInflater.from(this);
LayoutInflaterCompat.setFactory2(layoutInflater, this);
super.onCreate(savedInstanceState);
}
这是因为我们想要设置自定义的LayoutInflater
,而AppCompatActivity
内部也调用了setFactory
,所以如果我们再调用setFactory
就会打印出信息如下(不是报错,只是自定义的Inflater无效):
The Activity's LayoutInflater already has a Factory installed so we can not install AppCompat's
源码追踪:
AppCompatActivity-> AppCompatDelegate ->AppCompatDelegateImpl
AppCompatActivity.onCreate()
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
getDelegate().installViewFactory();
getDelegate().onCreate(savedInstanceState);
super.onCreate(savedInstanceState);
}
可以看出AppCompatDelegateImpl
实现了LayoutInflater.Factory2
接口;
class AppCompatDelegateImpl extends AppCompatDelegate
implements MenuBuilder.Callback, LayoutInflater.Factory2 {
AppCompatDelegateImpl.installViewFactory()
@Override
public void installViewFactory() {
LayoutInflater layoutInflater = LayoutInflater.from(mContext);
//注意这里,如果我们在super.onCreate之前设置了自定义的Factory,
//这里就会走 != null,系统的setFactory2不会被调用;
//从而AppCompatDelegateImpl.onCreateView方法也不会被调用;
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");
}
}
}
AppCompatDelegateImpl Factory2
public interface Factory2 extends Factory {
@Nullable
View onCreateView(@Nullable View parent, @NonNull String name,
@NonNull Context context, @NonNull AttributeSet attrs);
}
可以看出,Factory2
是一个接口,onCreateView
是Factory2
的接口方法,如果我们在系统调用之前,也就是super.onCreate()
之前设置Factory,就会 就不会走系统的Factory的接口方法,而是走我们自己的onCreateView
然后我们看到自己重写的 onCreateView
方法,方法中的参数name
就是页面控件的名称(自定义的就是包名+类名,系统的就直接是类名),而attrs
就是控件属性了,我们使用了自定义的CustomAppCompatViewInflater
对其进行了收集,并执行了自定义AppCompatViewInflater中的autoMatch方法。
下面看CustomAppCompatViewInflater
/**
* TODO 第三大步骤 一个小步
* 自定义控件加载器(可以考虑该类不被继承)
*/
public final class CustomAppCompatViewInflater
extends AppCompatViewInflater
{
private String name; // 控件名
private Context context; // 上下文
private AttributeSet attrs; // 某控件对应所有属性
public CustomAppCompatViewInflater(@NonNull Context context) {
this.context = context;
}
public void setName(String name) {
this.name = name;
}
public void setAttrs(AttributeSet attrs) {
this.attrs = attrs;
}
/**
* @return 自动匹配控件名,并初始化控件对象
*/
public View autoMatch() {
View view = null;
switch (name) {
case "LinearLayout":
// view = super.createTextView(context, attrs); // 源码写法
view = new SkinnableLinearLayout(context, attrs);
this.verifyNotNull(view, name);
break;
case "RelativeLayout":
view = new SkinnableRelativeLayout(context, attrs);
this.verifyNotNull(view, name);
break;
case "TextView":
view = new SkinnableTextView(context, attrs);
this.verifyNotNull(view, name);
break;
case "ImageView":
view = new SkinnableImageView(context, attrs);
this.verifyNotNull(view, name);
break;
case "Button":
// button = new Button(); 虽然能实现,不能这样干,无法商用
view = new SkinnableButton(context, attrs);
this.verifyNotNull(view, name);
break;
}
return view;
}
/**
* 校验控件不为空(源码方法,由于private修饰,只能复制过来了。为了代码健壮,可有可无)
*
* @param view 被校验控件,如:AppCompatTextView extends TextView(v7兼容包,兼容是重点!!!)
* @param name 控件名,如:"ImageView"
*/
private void verifyNotNull(View view, String name) {
if (view == null) {
throw new IllegalStateException(this.getClass().getName() + " asked to inflate view for <" + name + ">, but returned null");
}
}
}
在这个Inflater中替换资源,使用我们自定义的Skinnable的View,即实现了换肤接口的View,这其中分为两种:
- 原生控件:如果需要实现换肤功能,建议统一在换肤module里定义,因为会利用自定义Inflater在autoMatch方法中替换,所以布局中还是正常写成原生控件。
- 自定义控件:定义在主工程中,可以直接实现换肤接口即可,在换肤接口方法中利用SkinManager来判断和获取日夜间模式。
下面举例原生控件、自定义控件的代码示例
/**
* 继承TextView兼容包,9.0源码中也是如此
* 参考:AppCompatViewInflater.java
* 86行 + 138行 + 206行
*/
public class SkinnableTextView extends AppCompatTextView implements ViewsMatch {
private AttrsBean attrsBean;
public SkinnableTextView(Context context) {
this(context, null);
}
public SkinnableTextView(Context context, AttributeSet attrs) {
this(context, attrs, android.R.attr.textViewStyle);
}
public SkinnableTextView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
attrsBean = new AttrsBean();
// 根据自定义属性,匹配控件属性的类型集合,如:background + textColor
TypedArray typedArray = context.obtainStyledAttributes(attrs,
R.styleable.SkinnableTextView,
defStyleAttr, 0);
// 存储到临时JavaBean对象
attrsBean.saveViewResource(typedArray, R.styleable.SkinnableTextView);
// 这一句回收非常重要!obtainStyledAttributes()有语法提示!!
typedArray.recycle();
}
@Override
public void skinnableView() {
// 根据自定义属性,获取styleable中的background属性
int key = R.styleable.SkinnableTextView[R.styleable.SkinnableTextView_android_background];
// 根据styleable获取控件某属性的resourceId
int backgroundResourceId = attrsBean.getViewResource(key);
if (backgroundResourceId > 0) {
// 是否默认皮肤
if (SkinManager.getInstance().isDefaultSkin()) {
// 兼容包转换
Drawable drawable = ContextCompat.getDrawable(getContext(), backgroundResourceId);
// 控件自带api,这里不用setBackgroundColor()因为在9.0测试不通过
// setBackgroundDrawable本来过时了,但是兼容包重写了方法
setBackgroundDrawable(drawable);
} else {
// 获取皮肤包资源
Object skinResourceId = SkinManager.getInstance().getBackgroundOrSrc(backgroundResourceId);
// 兼容包转换
if (skinResourceId instanceof Integer) {
int color = (int) skinResourceId;
setBackgroundColor(color);
// setBackgroundResource(color); // 未做兼容测试
} else {
Drawable drawable = (Drawable) skinResourceId;
setBackgroundDrawable(drawable);
}
}
}
// 根据自定义属性,获取styleable中的textColor属性
key = R.styleable.SkinnableTextView[R.styleable.SkinnableTextView_android_textColor];
int textColorResourceId = attrsBean.getViewResource(key);
if (textColorResourceId > 0) {
if (SkinManager.getInstance().isDefaultSkin()) {
ColorStateList color = ContextCompat.getColorStateList(getContext(), textColorResourceId);
setTextColor(color);
} else {
ColorStateList color = SkinManager.getInstance().getColorStateList(textColorResourceId);
setTextColor(color);
}
}
// 根据自定义属性,获取styleable中的字体 custom_typeface 属性
key = R.styleable.SkinnableTextView[R.styleable.SkinnableTextView_custom_typeface];
int textTypefaceResourceId = attrsBean.getViewResource(key);
if (textTypefaceResourceId > 0) {
if (SkinManager.getInstance().isDefaultSkin()) {
setTypeface(Typeface.DEFAULT);
} else {
setTypeface(SkinManager.getInstance().getTypeface(textTypefaceResourceId));
}
}
}
}
// 自定义控件
public class CustomCircleView extends View implements ViewsMatch {
private Paint mTextPain;
private AttrsBean attrsBean;
public CustomCircleView(Context context) {
this(context, null);
}
public CustomCircleView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public CustomCircleView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
attrsBean = new AttrsBean();
// 根据自定义属性,匹配控件属性的类型集合,如:circleColor
TypedArray typedArray = context.obtainStyledAttributes(attrs,
R.styleable.CustomCircleView,
defStyleAttr, 0);
int corcleColorResId = typedArray.getResourceId(R.styleable.CustomCircleView_circleColor, 0);
// 存储到临时JavaBean对象
attrsBean.saveViewResource(typedArray, R.styleable.CustomCircleView);
// 这一句回收非常重要!obtainStyledAttributes()有语法提示!!
typedArray.recycle();
mTextPain = new Paint();
mTextPain.setColor(getResources().getColor(corcleColorResId));
//开启抗锯齿,平滑文字和圆弧的边缘
mTextPain.setAntiAlias(true);
//设置文本位于相对于原点的中间
mTextPain.setTextAlign(Paint.Align.CENTER);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 获取宽度一半
int width = getWidth() / 2;
// 获取高度一半
int height = getHeight() / 2;
// 设置半径为宽或者高的最小值(半径)
int radius = Math.min(width, height);
// 利用canvas画一个圆
canvas.drawCircle(width, height, radius, mTextPain);
}
@Override
public void skinnableView() {
// 根据自定义属性,获取styleable中的circleColor属性
int key = R.styleable.CustomCircleView[R.styleable.CustomCircleView_circleColor];
int resourceId = attrsBean.getViewResource(key);
if (resourceId > 0) {
if (SkinManager.getInstance().isDefaultSkin()) {
int color = ContextCompat.getColor(getContext(), resourceId);
mTextPain.setColor(color);
} else {
int color = SkinManager.getInstance().getColor(resourceId);
mTextPain.setColor(color);
}
}
// 根据自定义属性,获取styleable中的textColor属性
key = R.styleable.CustomCircleView[R.styleable.CustomCircleView_android_background];
int backgroundResourceId = attrsBean.getViewResource(key);
if (backgroundResourceId > 0) {
if (SkinManager.getInstance().isDefaultSkin()) {
Drawable drawable = ContextCompat.getDrawable(getContext(), backgroundResourceId);
// 控件自带api,这里不用setBackgroundColor()因为在9.0测试不通过
// setBackgroundDrawable本来过时了,但是兼容包重写了方法
setBackgroundDrawable(drawable);
} else {
// 获取皮肤包资源
Object skinResourceId = SkinManager.getInstance().getBackgroundOrSrc(backgroundResourceId);
// 兼容包转换
if (skinResourceId instanceof Integer) {
int color = (int) skinResourceId;
setBackgroundColor(color);
// setBackgroundResource(color); // 未做兼容测试
} else {
Drawable drawable = (Drawable) skinResourceId;
setBackgroundDrawable(drawable);
}
}
}
invalidate();
}
}
可以看出,ViewsMatch就是前文那个换肤接口,实现skinnableView的换肤回调方法,要注意的是需要换肤的属性都要自定义在attrs中,无论是自定义控件还是系统控件,也无论自定义的还是系统View自带的属性,因为只有在attrs中定义了,attrsBean才能收集到这个属性,以提供给控件换肤时使用。
<!-- 自定义控件属性 -->
<declare-styleable name="CustomCircleView">
<attr name="circleColor" format="color" />
<attr name="android:background" />
</declare-styleable>
再来看一个关键类SkinManager
/**
* 皮肤管理器
* 加载应用资源(app内置:res/xxx) or 存储资源(下载皮肤包:net163.skin)
*/
public class SkinManager {
private static SkinManager instance; // 单例
private Application application; // 拥有用户App环境,所以需要 application
private Resources appResources; // 用于加载app内置资源 【本地Resources】
private Resources skinResources; // 用于加载皮肤包资源 【外置Resources】
private String skinPackageName; // 皮肤包资源所在包名(注:皮肤包不在app内,也不限包名)【外置皮肤包详细信息】
private boolean isDefaultSkin = true; // 应用默认皮肤(app内置)【标记 换肤 和 默认】
private static final String ADD_ASSET_PATH = "addAssetPath"; // 方法名
private Map<String, SkinCache> cacheSkin; // 任何时候尽量用缓存
private SkinManager(Application application) {
this.application = application;
appResources = application.getResources();
cacheSkin = new HashMap<>();
}
/**
* 单例方法,目的是初始化app内置资源(越早越好,用户的操作可能是:换肤后的第2次冷启动)
*/
public static void init(Application application) {
if (instance == null) {
synchronized (SkinManager.class) {
if (instance == null) {
instance = new SkinManager(application);
}
}
}
}
public static SkinManager getInstance() {
return instance;
}
/**
* 加载皮肤包资源
*
* 收集皮肤包里面的所有信息
*
* @param skinPath 皮肤包路径,为空则加载app内置资源
*/
public void loaderSkinResources(String skinPath) {
// 优化:如果没有皮肤包或者没做换肤动作,方法不执行直接返回!
if (TextUtils.isEmpty(skinPath)) {
isDefaultSkin = true;
return;
}else{
File file = new File(skinPath);
Log.e("loaderSkinResources","file.exists()="+file.exists());
if(!file.exists()){
isDefaultSkin = true;
return;
}
}
// 优化:app冷启动、热启动可以取缓存对象
if (cacheSkin.containsKey(skinPath)) {
isDefaultSkin = false;
SkinCache skinCache = cacheSkin.get(skinPath);
if (null != skinCache) {
// 皮肤包资源 --- 图片 颜色 ... 信息集
skinResources = skinCache.getSkinResources();
// 皮肤薄路径---native层换肤需要的信息
skinPackageName = skinCache.getSkinPackageName();
return;
}
}
// 开始真正的换肤 AssetManager.java -----> AssetManager.cpp {换肤 皮肤包是否合格 AndroidManifest.xml}
try {
// 创建资源管理器(此处不能用:application.getAssets())
AssetManager assetManager = AssetManager.class.newInstance();
// 由于AssetManager中的addAssetPath和setApkAssets方法都被@hide,目前只能通过反射去执行方法
Method addAssetPath = assetManager.getClass().getDeclaredMethod(ADD_ASSET_PATH, String.class);
// 设置私有方法可访问
addAssetPath.setAccessible(true);
// 执行addAssetPath方法
addAssetPath.invoke(assetManager, skinPath);
//==============================================================================
// 如果还是担心@hide限制,可以反射addAssetPathInternal()方法,参考源码366行 + 387行
//==============================================================================
// 创建加载外部的皮肤包(net163.skin)文件Resources(注:依然是本应用加载)
skinResources = new Resources(assetManager,
appResources.getDisplayMetrics(), appResources.getConfiguration());
// 如果拿不到 packageName 证明 不是皮肤包 ,因为 皮肤包 有清单文件 和 资源映射表
// 根据apk文件路径(皮肤包也是apk文件),获取该应用的包名。兼容5.0 - 9.0(亲测)
skinPackageName = application.getPackageManager()
.getPackageArchiveInfo(skinPath, PackageManager.GET_ACTIVITIES).packageName;
// 无法获取皮肤包应用的包名,则加载app内置资源
isDefaultSkin = TextUtils.isEmpty(skinPackageName);
if (!isDefaultSkin) {
// 没有问题 就存储
cacheSkin.put(skinPath, new SkinCache(skinResources, skinPackageName));
}
Log.e("skinPackageName >>> ", skinPackageName);
} catch (Exception e) {
e.printStackTrace();
// 发生异常,预判:通过skinPath获取skinPacakageName失败!
isDefaultSkin = true;
}
}
/**
* 参考:resources.arsc资源映射表
* 通过ID值获取资源 Name 和 Type
*
* 能够 自动 区分 本地app资源加载 还是 皮肤包资源加载
* resourceId == ghsy
*
* @param resourceId 资源ID值
* @return 如果没有皮肤包则加载app内置资源ID,反之加载皮肤包指定资源ID
*/
private int getSkinResourceIds(int resourceId) {
// 优化:如果没有皮肤包或者没做换肤动作,直接返回app内置资源!
if (isDefaultSkin) return resourceId; // 本地app资源加载
// TODO 加载 皮肤包里面的资源
// 使用app内置资源加载,是因为内置资源与皮肤包资源一一对应(“netease_bg”, “drawable”)
String resourceName = appResources.getResourceEntryName(resourceId);
String resourceType = appResources.getResourceTypeName(resourceId);
// 动态获取皮肤包内的指定资源ID
// getResources().getIdentifier(“netease_bg”, “drawable”, “com.netease.skin.packages”);
int skinResourceId = skinResources.getIdentifier(resourceName, resourceType, skinPackageName);
// 源码1924行:(0 is not a valid resource ID.)
isDefaultSkin = skinResourceId == 0;
return skinResourceId == 0 ? resourceId : skinResourceId;
}
public boolean isDefaultSkin() {
return isDefaultSkin;
}
//==============================================================================================
public int getColor(int resourceId) {
int ids = getSkinResourceIds(resourceId);
return isDefaultSkin ? appResources.getColor(ids) : skinResources.getColor(ids);
}
public ColorStateList getColorStateList(int resourceId) {
int ids = getSkinResourceIds(resourceId);
return isDefaultSkin ? appResources.getColorStateList(ids) : skinResources.getColorStateList(ids);
}
// mipmap和drawable统一用法(待测)
public Drawable getDrawableOrMipMap(int resourceId) {
int ids = getSkinResourceIds(resourceId);
return isDefaultSkin ? appResources.getDrawable(ids) : skinResources.getDrawable(ids);
}
public String getString(int resourceId) {
int ids = getSkinResourceIds(resourceId);
return isDefaultSkin ? appResources.getString(ids) : skinResources.getString(ids);
}
// 返回值特殊情况:可能是color / drawable / mipmap
public Object getBackgroundOrSrc(int resourceId) {
// 需要获取当前属性的类型名Resources.getResourceTypeName(resourceId)再判断
String resourceTypeName = appResources.getResourceTypeName(resourceId);
switch (resourceTypeName) {
case "color":
return getColor(resourceId);
case "mipmap": // drawable / mipmap
case "drawable":
return getDrawableOrMipMap(resourceId);
}
return null;
}
// 获得字体
public Typeface getTypeface(int resourceId) {
// 通过资源ID获取资源path,参考:resources.arsc资源映射表
String skinTypefacePath = getString(resourceId);
// 路径为空,使用系统默认字体
if (TextUtils.isEmpty(skinTypefacePath)) return Typeface.DEFAULT;
return isDefaultSkin ? Typeface.createFromAsset(appResources.getAssets(), skinTypefacePath)
: Typeface.createFromAsset(skinResources.getAssets(), skinTypefacePath);
}
}
有点长,但是并不复杂,这个类的作用
- 利用反射AssetManager调用addAssetPath方法加载皮肤包
- 提供了一些列getColor getDrawableOrMipMap等方法,供用户在换肤方法中调用,因为换肤方法中要根据条件(比如是日间还是夜间)来获取不同的资源,主工程中的资源直接可以通过context获取,而资源包中的资源则得通过自建的assetManager来获取。
总结
- 换肤Activity继承SkinActivity
- SkinActivity中,在super.onCreate系统设置之前设置Factory2
- SkinActivity中,在onCreateView中使用自定义控件加载器(CustomAppCompatViewInflater),记录控件属性,将需要替换的控件替换成实现了换肤接口的自定义控件。(需换肤的属性问题:通过attrs解决;不同皮肤包的资源获取问题:通过SkinManager.getColor等解决)
- SkinActivity中,在skinDynamic中进行真正的换肤流程(对getWindow().getDecorView()进行迭代循环,使用接口方法skinnableView进行换肤)
网友评论