写在前面

Android TV 电视开发,主题换肤,我感觉有两种层级的方式,一种是 系统级,另一种 是应用级,

我记得很早在 Linux 桌面开发的时候,我们之前的公司在GTK+上也实现了一套换肤UI框架,

包括我们看到的QQ,掘金,简书,等应用,无缝切入主题,不需要重启应用。

为何需要主题切换,因为UI库改来改去也就那些东西,不过在变换一些样式,阴影,圆角,文本大小,颜色,边框,背景 等等而已。

那么其实我们可以通用主题的配置方式,统一配置更改样式.

几种方案探讨

下面探讨的方案,都是不需要重启应用,及时生效的方案。
主题切换的问题在于,新的出来的界面,还有已经存在的界面。

QMUI 主题换肤方案

QMUI的方案,定义各种 theme style,通过遍历已存在的View,然后设置相关属性,并且设置 Theme.;包括后续新出的控件也是已切换的主题样式.

public void changeSkin(Object ob, int skinRes, Resources resources) {
	View view = null;
    if (null != ob) {
		if (ob instanceof Activity) { // 普通的 Activity支持
        	Activity activity = (Activity) ob;
            Drawable drawable = getAttrDrawable(activity, activity.getTheme(), R.attr.tvui_commonItem_detailColor);
           	activity.getWindow().setBackgroundDrawable(drawable);
            // 获取 content view.
        	view = activity.findViewById(android.R.id.content);
		} else if (ob instanceof Dialog) { // 系统设置5.0 - Dialog 主题切换支持
	        Window window = ((Dialog) ob).getWindow();
            if (window != null) {
            	view = window.getDecorView();
            }
		} else if (ob instanceof View) { // 普通的 View 主题切换支持
        	view = (View) ob;
		}
		mResources = resources != null ? resources : view.getResources();
		if (null != view) {
		    // 切换主题样式
		    theme = mResources.newTheme();
            theme.applyStyle(skinRes, true);
			// 设置背景颜色
			ColorStateList bgColorStateList = getAttrColorStateList(
                        mContext, theme, R.attr.tvui_main_layout_background_color);
            view.setBackgroundColor(bgColorStateList.getDefaultColor());
            // 切换主题样式,遍历所有子View...
			setThemeDatas(view, skinRes, theme);
		}
	}
}

// 遍历Views切换主题样式
private void setThemeDatas(View view, int skinRes, Resources.Theme theme) {
	applyTheme(view, skinRes, theme);
    if (view instanceof ViewGroup) {
		ViewGroup viewGroup = (ViewGroup) view;
		for (int i = 0; i < viewGroup.getChildCount(); i++) {
        	setThemeDatas(viewGroup.getChildAt(i), skinRes, theme);
		}
    }
}

private void applyTheme(View view, int skinRes, Resources.Theme theme) {
	if (view instanceof Button) {
    	((Button)view).setTextColor(getAttrColorStateList(mContext, theme, R.attr.tvui_text_color).getDefaultColor());
    }
}

// 添加了测试的 背景颜色与文本颜色
<attr name="tvui_main_layout_background_color" format="color"/>
<attr name="tvui_text_color" format="color"/>

<style name="NewAppTheme" parent="xxxx">
        <item name="tvui_main_layout_background_color">#FF0000</item>
        <item name="tvui_text_color">#0000FF</item>
</style>


// 切换主题
SkinManager.getInstance(AnimHomeActivity.this).changeSkin(AnimHomeActivity.this, R.style.NewAppTheme);

切换主题效果如下

android 全屏主题 转孔屏 安卓大屏更换主题_主题切换


上面会存在一个问题,已经切换了主题样式,新出来的界面控件这么办???????
为了使后续启动的 新出来的界面控件也是切换后的主题,需要作出如下修改

public void changeSkin(Object ob, int skinRes, Resources resources) {
	View view = null;
	... ...
	view.setBackgroundColor(bgColorStateList.getDefaultColor());
	// 新增 随意一个都可以,设置后,后续新出来的控件都是现在切换的主题样式
	view.getContext().setTheme(skinRes);
	//view.getContext().getTheme().setTo(theme);
}

// 主题 style
<attr name="TVUIRoundButtonStyle" format="reference"/>
<declare-styleable name="UIRoundButton">
	<attr name="tvui_text_color"/>
</declare-styleable>

// 默认主题使用
<style name="TVUITheme.UIRoundButton">
	<item name="tvui_text_color">#FFEB3B</item>
</style>

// NewAppTheme 使用
<style name="TVUITheme.UIRoundButton.New">
	<item name="tvui_text_color">#2196F3</item>
</style>

 <style name="NewAppTheme" parent="xxxxx">
    <item name="tvui_main_layout_background_color">#FF0000</item>
  	<item name="tvui_text_color">#0000FF</item>
	<item name="TVUIRoundButtonStyle">@style/TVUITheme.UIRoundButton.New</item>
</style>

// 测试UIButton代码
public class TVUIButton extends Button {
    public TVUIButton(Context context) {
        this(context, null);
    }

    public TVUIButton(Context context, AttributeSet attrs) {
        this(context, attrs, R.attr.TVUIRoundButtonStyle);
    }

    public TVUIButton(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.UIRoundButton, defStyleAttr, 0);
        int textColor = ta.getColor(R.styleable.UIRoundButton_tvui_text_color, Color.BLACK);
        setTextColor(textColor);
        setTextSize(152);
        ta.recycle();
    }
}

新增控件的效果

android 全屏主题 转孔屏 安卓大屏更换主题_主题换肤_02


补充,如果感觉麻烦,也可以直接在布局里面使用 attr 的属性,就不需要定义一堆的和button相关的Style.
但是前提是你需要定义好你自己的 相关值,比如阴影多少,圆角的角度,一级文本,二级文本属性等等,这里可以称为 通用属性.

<attr name="tvui_text_color" format="color"/>

<style name="NewAppTheme" parent="xxxx">
	<item name="tvui_text_color">#0000FF</item>
</style>

// 布局里面使用
android:textColor="?attr/tvui_text_color"
// 代码中使用
ColorStateList textColor = SkinManager.getAttrColorStateList(getContext(), R.attr.tvui_text_color);

这套主题的优/缺点:

  • 简单方便,适用于 只有亮/暗的主题切换.
  • 缺点也明显,如果要新增主题,就要更改或者新增,很麻烦,会导致重新发布APK.

当然,因为某些局限性,肯定使用场景不同. 如果你是那种需要下载以及加载各种 酷炫主题包的,这种方案肯定不适合你.
如果只是UI库统一配置(新的UI界面,只需要再原来的基础继承,就可以产生一套新的),只有亮/暗主题切换,简单,方便.

参考资料:
Android如何在代码中获取attr属性的值

Andrid-Skin-Loader 主题切换方案

git项目地址:

Andrid-Skin-Loader 流程步骤:

  • 初始化加载APK主题包
  • 通用 Factory 遍历 View,保存相关View与属性的信息
  • 最后切换主题的时候,重新调用 SkinManager 来获取相关主题包的资源属性.

加载APK主题包

// 这里会有点卡,建议放在异步线程里面执行相关操作
String skinPkgPath = "皮肤的apk路径";				
File file = new File(skinPkgPath); 
if(file == null || !file.exists()){
	return null;
}
						
PackageManager mPm = context.getPackageManager();
// getPackageArchiveInfo文章: 
PackageInfo mInfo = mPm.getPackageArchiveInfo(skinPkgPath, PackageManager.GET_ACTIVITIES);
skinPackageName = mInfo.packageName;

AssetManager assetManager = AssetManager.class.newInstance();
// 调用addAssetPath方法将皮肤包加载进内存
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
addAssetPath.invoke(assetManager, skinPkgPath);

Resources superRes = context.getResources();
Resources skinResource = new Resources(assetManager, superRes.getDisplayMetrics(),superRes.getConfiguration()); // apk皮肤包的资源句柄
						
skinPath = skinPkgPath;
isDefaultSkin = false;

return skinResource;

Factory 遍历 View,保存相关view信息
因为做主题切换的,已经显示出来的没有办法更新,所以需要保存相关信息,切换的时候,就更新这些view的信息,达到主题切换的效果

public class SkinInflaterFactory implements Factory {
	public View onCreateView(String name, Context context, AttributeSet attrs) {
		view = LayoutInflater.from(context).createView(name, "android.view.", attrs);
		// 创建 view.
		view = LayoutInflater.from(context).createView(name, null, attrs);
		// 这里可以判断 view 的 name,是不是 原生的,你也可以替换成你的
		//
		return view;
	}
	// 解析view的 attrs 的属性,保存view的相关信息
	private void parseSkinAttr(Context context, AttributeSet attrs, View view) {
	}
}

// Activity或者其它地方如何使用??
getLayoutInflater().setFactory(new SkinInflaterFactory());

但是如果要达到遍历View的效果,也可以参考 changeSkin,不一定要保存相关信息的

SkinManager 相关函数

// 获取颜色值 比如 <color name="tvui_bg_color">#ecf0f1</color>
public int getColor(int resId){
	int originColor = context.getResources().getColor(resId);
	// 如果APK资源 mResources为空 或 使用默认的,就返回当前APP默认的资源
	if(mResources == null || isDefaultSkin) {
		return originColor;
	}
	// 获取 ID 的字符串名称 	,这里返回的是 tvui_bg_color
	String resName = context.getResources().getResourceEntryName(resId);
	// getResourceTypeName(resId) 获取 color, drawable 等类型,这里返回的就是 color
	
	// 通过APK资源的 mResources 获取APK 相关的资源.(类型,名字相关,值不一样的)
	// 比如APK资源里面是这样:<color name="tvui_bg_color">#FF0000</color>
	int trueResId = mResources.getIdentifier(resName, "color", skinPackageName);
	int trueColor = 0;
		
	try{
		trueColor = mResources.getColor(trueResId);
	} catch(NotFoundException e) {
		e.printStackTrace();
		trueColor = originColor;
	}
		
	return trueColor;
}
// 获取Drawble
getDrawable... ...

上面已经提到当前已经显示的,那么后面新出来的控件这么办?这个就需要再代码里面编写相关代码才行.

有人肯定会想,我将 Activity 的 getResources 变成 APK资源的 resources 不就完啦,哈哈哈,肯定不是这样的.

public Resources getResources() {
        Resources resources = SkinManagerApk.getInstance().getResources();
        if (null != resources) {
            return resources;
        }
        return super.getResources();
}
// getResources().getColorStateList(R.color.news_item_text_color_selector);

不能以上面的方式获取,因为 getColorStateList(… …); 这个资源的ID是本APK的,不是主题资源APK里面的.那要这么办?
只能使用 SkinManager getColor 类似的方式.
通用 getResourceEntryName 获取到 news_item_text_color_selector 的名称.
然后再 通用 mResources.getIdentifier(“news_item_text_color_selector”, “color”, skinPackageName); 获取资源APK的ID.
最后通用 mResources.getColorStateList(trueResId); 获取相关资源.
具体代码参考:

  • Andrid-Skin-Loader 的 SkinManager
  • 也可以参考我缩减的代码 SkinMangerApk

DEMO代码

优缺点探讨:

  • 可以进行主题包下载,多主题更新,非常方便
  • 局限性就是 你再 XML布局写的默认的,下次显示出来还是原来默认的,就需要再代码里面进行 已经切换主题的 资源获取,用代码填充上去,侵入式的.
  • 每次都要重新加载APK资源包

参考资料:Android换肤原理和Android-Skin-Loader框架解析

热更新资源替换方案

我们知道,系统级的替换主题都是 替换的资源ID. 那我们思考一下,能不能有一种主题切换方式,我们加载的主题APK资源包,就能替换当前APP默认的资源,我们不用手写代码去填写覆盖?也能及时更新资源?

目前市面上的很多热修复方案基本上都参考了Instant Run的是实现。

简单来说,Instant Run中的资源热修复方案分为两步:

构造一个新的AssetManager,并通过反射调用addAssetPath,把这个完整的新资源包加入到AssetManager中,这样就得到了一个含有所有新资源的AssetManager。
找到所有之前引用到原有AssetManager的地方,通过反射,把引用处替换为新的AssetManager。
其实,在该方案中有大量的代码都是在处理兼容性问题和找到所有AssetManager的引用。真正替换的代码其实很简单。

Sophix并没有直接采用Instant Run技术,而是构造了一个package id为0x66的资源包,其实这个资源包里面只有修改了的资源项,直接在原有的AssetManager中addAssetPath就可以了。

由于补丁包的Package Id为0x66,不与目前已经加载的0x7f资源段冲突,因此直接加入到已有的AssetManager中就可以直接使用了。

android 全屏主题 转孔屏 安卓大屏更换主题_Android_03


相关代码:

https://gitee.com/hailongqiu/OpenDemo/tree/master/app/src/main/java/com/open/demo/skin/instant

参考资料:

AOP方案切换主题

经过我查询,原来可以替换 resource,做一个钩子;那么我们获取到APK资源的 resource,替换掉其它相关的 resource 应该就可以达到这样的效果。包括布局XML的,新出来的等等… …

代理 View.OnClick… Window.Callback 相关函数,钩子…

View.AccessibilityDelegate

ASM,AST

Android AOP三剑客:APT, AspectJ 和 Javassist

  • APT应用:Dagger,butterKnife,组件化方案等等
  • AspectJ:主要用于性能监控,日志埋点等
  • Javassist:热更新(可以在编译后,打包Dex之前干事情,可以突破一下限制)