作者: Yagami3zZhttps://www.jianshu.com/p/c2b1ae56381e
前言:
本文主要讲述如何在项目中, 在不重启应用的情况下, 实现动态换肤的效果. 换肤这块做的比较好的, 有网易云音乐, qq 等, 给用户带来了多样的界面选择和个性化定制. 之前看到换肤的效果后对这块也比较好奇, 就抽时间研究了下, 今天给大家分享解析原理和实践中遇到的问题.
为什么要做动态换肤:
动态换肤可以满足日常产品和运营需求, 满足用户个性化界面定制的需求等等.
动态换肤, 相比于静态皮肤, 可以减小 apk 大小
皮肤模块独立便于维护
由服务器下发, 不需要发版即可实现动态更新
换肤的一般实现思路:
资源打包静态替换方案: 指定资源路径地址, 在打包时将对应资源打包进去 build.gradle 中进行对应配置
sourceSets {// 测试版本和线上版本用同一套资源 YymTest { res.srcDirs = ["src/Yym/res", "src/YymTest/res"] assets.srcDirs = ["src/Yym/assets"] }}
这种方式是在打包时, 通过指定资源文件的路径在编译打包时将对应的资源打包进去, 以实现不同的主题样式等换肤需求. 适合发布马甲版本的 App 需求.
动态换肤方案: 应用运行时, 选择皮肤后, 在主 App 中拿到对应皮肤包的 Resource, 将皮肤包中的资源动态加载到应用中展示并呈现给用户.
动态换肤的一般步骤为:
下载并加载皮肤包
拿到皮肤包 Resource 对象
标记需要换肤的 View
切换时即时刷新页面
制作皮肤包
换肤整体框架的搭建
如何拿到皮肤包 Resouce 对象:
PackageManager mPm = context.getPackageManager(); PackageInfo mInfo = mPm.getPackageArchiveInfo(skinPkgPath, PackageManager.GET_ACTIVITIES);skinPackageName = mInfo.packageName;AssetManager assetManager = AssetManager.class.newInstance();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());
其中需要传入的参数即为皮肤包的文件路径地址, 还有当前 App 的 context 其中 superResource 为当前 App 的 Resource 对象, 而 skinResource 即为加载后的皮肤包的 Resource 对象. 皮肤包的资源即可通过 skinResource.getIdentifier(resName, "color", skinPackageName); 这种方式拿到了.
如何标记需要换肤的 View
如何找到需要换肤的 View
1)通过 xml 标记的 View: 这种方式主要要通过实现 LayoutInflate.Factory2 这个接口(为支持 AppcompotActivty 用 LayoutInflaterFactory API 是一样的).
/*** Used with {@code LayoutInflaterCompat.setFactory()}. Offers the same API as* {@code LayoutInflater.Factory2}.*/public interface LayoutInflaterFactory {/** * Hook you can supply that is called when inflating from a LayoutInflater. * You can use this to customize the tag names available in your xml * layout files. * * @param parent The parent that the created view will be placed * in; <em>note that this may be null</em>. * @param name Tag name to be inflated. * @param context The context the view is being created in. * @param attrs Inflation attributes as specified in xml file. * * @return View Newly created view. Return null for the default * behavior. */public View onCreateView(View parent, String name, Context context, AttributeSet attrs);}
LayoutInflater 提供了 setFactory(LayoutInflater.Factory factory)和 setFactory2(LayoutInflater.Factory2 factory)两个方法可以让你去自定义布局的填充 (有点类似于过滤器, 我们在填充这个 View 之前可以做一些额外的事),Factory2 是在 API 11 才添加的. 通过实现这两个接口可以实现 View 的重写. Activity 本身就默认实现了 Factory 接口, 所以我们复写了 Factory 的 onCreateView 之后, 就可以不通过系统层而是自己截获从 xml 映射的 View 进行相关 View 创建的操作, 包括对 View 的属性进行设置(比如背景色, 字体大小, 颜色等) 以实现换肤的效果. 如果 onCreateView 返回 null 的话, 会将创建 View 的操作交给 Activity 默认实现的 Factory 的 onCreateView 处理.
- SkinInflaterFactory:
- public class SkinInflaterFactory implements LayoutInflaterFactory {
- private static final boolean DEBUG = true;/** * Store the view item that need skin changing in the activity */private List<SkinItem> mSkinItems = new ArrayList<SkinItem>();@Overridepublic View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
- // if this is NOT enable to be skined , simplly skip it boolean isSkinEnable = attrs.getAttributeBooleanValue(SkinConfig.NAMESPACE, SkinConfig.ATTR_SKIN_ENABLE, false); Log.d("ansen", "isSkinEnable----->" + isSkinEnable); Log.d("ansen", "name----->" + name); if (!isSkinEnable) {
- return null;
- } View view = createView(context, name, attrs); if (view == null) {
- return null;
- } parseSkinAttr(context, attrs, view); return view;
- }/** * Invoke low-level function for instantiating a view by name. This attempts to * instantiate a view class of the given <var>name</var> found in this * LayoutInflater's ClassLoader. * * @param context * @param name The full name of the class to be instantiated. * @param attrs The XML attributes supplied for this instance. * @return View The newly instantiated view, or null. */private View createView(Context context, String name, AttributeSet attrs) { View view = null; try { if (-1 == name.indexOf('.')) {
- view = createViewFromPrefix(context, name,"android.view.", attrs); if (view == null) {
- view=createViewFromPrefix(context, name,"android.widget.", attrs); if(view==null){
- view= createViewFromPrefix(context, name,"android.webkit.", attrs);
- }
- }
- } else {
- L.i(" 自定义 View to create "+ name); view=createViewFromPrefix(context, name, null, attrs);
- }
- } catch (Exception e) {
- L.e("error while create ["+ name +"] : " + e.getMessage()); view = null;
- } return view;}private View createViewFromPrefix(Context context, String name, String prefix, AttributeSet attrs) {
- View view; try {
- view = createView(context, name, prefix, attrs);
- } catch (Exception e) {
- view = null;
- } return view;
- }public void applySkin() {
- if (ListUtils.isEmpty(mSkinItems)) {
- return;
- } for (SkinItem si : mSkinItems) {
- if (si.view == null) {
- continue;
- } si.apply();
- }
- }public void addSkinView(SkinItem item) {
- mSkinItems.add(item);
- }}
对 View 属性进行识别并转化为皮肤属性实体
/** * Collect skin able tag such as background , textColor and so on * * @param context * @param attrs * @param view */ private void parseSkinAttr(Context context, AttributeSet attrs, View view) { List<SkinAttr> viewAttrs = new ArrayList<SkinAttr>(); for (int i = 0; i < attrs.getAttributeCount(); i++) { String attrName = attrs.getAttributeName(i); String attrValue = attrs.getAttributeValue(i); if (!AttrFactory.isSupportedAttr(attrName)) { continue; } if (attrValue.startsWith("@")) { try { int id = Integer.parseInt(attrValue.substring(1)); String entryName = context.getResources().getResourceEntryName(id); String typeName = context.getResources().getResourceTypeName(id); SkinAttr mSkinAttr = AttrFactory.get(attrName, id, entryName, typeName); if (mSkinAttr != null) { viewAttrs.add(mSkinAttr); } } catch (NumberFormatException e) { e.printStackTrace(); } catch (NotFoundException e) { e.printStackTrace(); } } } if (!ListUtils.isEmpty(viewAttrs)) { SkinItem skinItem = new SkinItem(); skinItem.view = view; skinItem.attrs = viewAttrs; mSkinItems.add(skinItem); if (SkinManager.getInstance().isExternalSkin()) { skinItem.apply(); } }
下面通过 skin:enbale="true" 这种方式, 对布局中需要换肤的 View 进行标记
<LinearLayout xmlns:Android="http://schemas.android.com/apk/res/android" xmlns:App="http://schemas.android.com/apk/res-auto" xmlns:skin="http://schemas.android.com/android/skin" xmlns:tools="http://schemas.android.com/tools" Android:layout_width="match_parent" Android:layout_height="match_parent" Android:orientation="vertical" Android:background="@color/hall_back_color" skin:enable="true" ><code.solution.widget.CustomActivityBar Android:id="@+id/custom_activity_bar" Android:layout_width="match_parent" Android:layout_height="@dimen/widget_action_bar_height" App:common_activity_title="@string/app_name" App:common_activity_title_gravity="center" App:common_activity_title_icon="@drawable/ic_win_cp" /></LinearLayout>
在 SKinInflaterFactory 的 onCreateView 方法中, 实际是对 xml 中映射的每个 View 进行过滤. 如果 skin:enbale 不为 true 则直接返回 null 交给系统默认去创建. 而如果为 true, 则自己去创建这个 View, 并将这个 VIew 的所有属性比如 id, width height,textColor,background 等与支持换肤的属性进行对比. 比如我们支持换 background textColor listSelector 等, Android:background="@color/hall_back_color" 这个属性, 在进行换肤的时候, 如果皮肤包里存在 hall_back_color 这个值的设置, 就将这个颜色值替换为皮肤包里的颜色值, 以完成换肤的需求. 同时, 也会将这个需要换肤的 View 保存起来.
如果在切换换肤之后, 进入一个新的页面, 就在进入这个页面 Activity 的 InlfaterFacory 的 onCreateView 里根据 skin:enable="true" 这个标记, 进行判断. 为 true 则进行换肤操作. 而对于切换换肤操作时, 已经存在的页面, 就对这几个存在页面保存好的需要换肤的 View 进行换肤操作.
2)在代码中动态添加的 View
上述是针对在布局中设置 skin:ebable="true" 的 View 进行换肤, 那么如果我们的 View 不是通过布局文件, 而是通过在代码种创建的 View, 怎样换肤呢?
public void dynamicAddSkinEnableView(Context context, View view, List<DynamicAttr> pDAttrs) { List<SkinAttr> viewAttrs = new ArrayList<SkinAttr>(); SkinItem skinItem = new SkinItem(); skinItem.view = view; for (DynamicAttr dAttr : pDAttrs) { int id = dAttr.refResId; String entryName = context.getResources().getResourceEntryName(id); String typeName = context.getResources().getResourceTypeName(id); SkinAttr mSkinAttr = AttrFactory.get(dAttr.attrName, id, entryName, typeName); viewAttrs.add(mSkinAttr); } skinItem.attrs = viewAttrs; skinItem.apply(); addSkinView(skinItem);}public void dynamicAddSkinEnableView(Context context, View view, String attrName, int attrValueResId) { int id = attrValueResId; String entryName = context.getResources().getResourceEntryName(id); String typeName = context.getResources().getResourceTypeName(id); SkinAttr mSkinAttr = AttrFactory.get(attrName, id, entryName, typeName); SkinItem skinItem = new SkinItem(); skinItem.view = view; List<SkinAttr> viewAttrs = new ArrayList<SkinAttr>(); viewAttrs.add(mSkinAttr); skinItem.attrs = viewAttrs; skinItem.apply(); addSkinView(skinItem);}
即在 Activity 中通过比如 dynamicAddSkinEnableView(context, mTextView,"textColor",R.color.main_text_color)即可完成对动态创建的 View 的换肤操作.
本文研究是基于 GitHub 开源项目 Android-Skin-Loader 进行的. 这个框架主要是动态加载皮肤包, 在不需要重启应用的前提下, 实现对页面布局等动态换肤的操作. 皮肤包独立制作和维护, 不和主工程产生耦合. 同时由后台服务器下发, 可即时在线更新不依赖客户端版本.
皮肤包的加载过程:
- SKinManger:
- public void load(String skinPackagePath, final ILoaderListener callback) {
- new AsyncTask<String, Void, Resources>() {
- protected void onPreExecute() {
- if (callback != null) {
- callback.onStart();
- }
- }; @Override protected Resources doInBackground(String... params) {
- try {
- if (params.length == 1) {
- String skinPkgPath = params[0]; File file = new File(skinPkgPath); if(file == null || !file.exists()){
- return null;
- } PackageManager mPm = context.getPackageManager(); PackageInfo mInfo = mPm.getPackageArchiveInfo(skinPkgPath, PackageManager.GET_ACTIVITIES); skinPackageName = mInfo.packageName; AssetManager assetManager = AssetManager.class.newInstance(); 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()); SkinConfig.saveSkinPath(context, skinPkgPath); skinPath = skinPkgPath; isDefaultSkin = false; return skinResource;
- } return null;
- } catch (Exception e) {
- e.printStackTrace(); return null;
- }
- }; protected void onPostExecute(Resources result) {
- mResources = result; if (mResources != null) {
- if (callback != null) callback.onSuccess(); notifySkinUpdate();
- }else{
- isDefaultSkin = true; if (callback != null) callback.onFailed();
- }
- };
- }.execute(skinPackagePath);
- }@Overridepublic void attach(ISkinUpdate observer) {
- if(skinObservers == null){
- skinObservers = new ArrayList<ISkinUpdate>();
- } if(!skinObservers.contains(observer)){
- skinObservers.add(observer);
- }
- }@Overridepublic void detach(ISkinUpdate observer) {
- if(skinObservers == null) return; if(skinObservers.contains(observer)){
- skinObservers.remove(observer);
- }
- }@Overridepublic void notifySkinUpdate() {
- if(skinObservers == null) return; for(ISkinUpdate observer : skinObservers){
- observer.onThemeUpdate();
- }
- }
SKinManager 为整个皮肤包的管理类, 负责加载皮肤包文件, 并得到该皮肤包的包名 skinPackageName, 和这个皮肤包的 Resource 对象 skinResource, 这样整个皮肤包的资源文件我们就都可以拿到了. 在加载得到皮肤包的 Resource 之后, 通知每个注册过 (attach) 的页面(Activity), 去刷新这些页面所有保存过的需要换肤的 View, 进行换肤操作.
切换时如何即时更新界面:
- 1,SkinBaseApplication:
- public class SkinApplication extends BaseApplication {
- @Overridepublic void onCreate() {
- super.onCreate(); SkinManager.getInstance().init(this); SkinManager.getInstance().load();
- }
- }
主要是进行一些初始化的操作.
- 2,SkinBaseActivity:
- public abstract class BaseActivity extends code.solution.base.BaseActivity implements ISkinUpdate, IDynamicNewView {
- private SkinInflaterFactory mSkinInflaterFactory;@Overrideprotected void onCreate(Bundle savedInstanceState) {
- mSkinInflaterFactory = new SkinInflaterFactory();LayoutInflaterCompat.setFactory(getLayoutInflater(), mSkinInflaterFactory);super.onCreate(savedInstanceState);changeStatusColor();
- }/** * dynamic add a skin view * * @param view * @param attrName * @param attrValueResId */protected void dynamicAddSkinEnableView(View view, String attrName, int attrValueResId){
- mSkinInflaterFactory.dynamicAddSkinEnableView(this, view, attrName, attrValueResId);
- }@Overridepublic void onThemeUpdate() {
- if(!isResponseOnSkinChanging){
- return;
- } mSkinInflaterFactory.applySkin(); changeStatusColor();
- }
在这里使用了之前自定义的 SkinInflaterFactory, 来替换默认的 Factory, 以达到截获创建 View, 获取 View 的属性, 与支持换肤的属性进行对比, 进行 View 换肤操作以及保存这些需要换肤的 View 到 List 中, 在下次换肤切换时对这些 View 进行换肤的目的.
其中换肤操作执行时, 会调用 SKinManager.notifySKinUpdate 方法
@Overridepublic void notifySkinUpdate() { if(skinObservers == null) return; for(ISkinUpdate observer : skinObservers){ observer.onThemeUpdate(); }}
而这里的 observer.onThemeUpdate 里面主要是执行这个 Activity 的下述方法:
public void onThemeUpdate() { if(!isResponseOnSkinChanging){ return; } mSkinInflaterFactory.applySkin(); changeStatusColor();}
mSkinInflaterFactory.applySkin(); 即为 SKinInflaterFactory 的 applySkin 方法,
public void applySkin() { if (ListUtils.isEmpty(mSkinItems)) { return; } for (SkinItem si : mSkinItems) { if (si.view == null) { continue; } si.apply(); } }
其中 mSKinItems 即为当前 Acitivty 通过 xml 文件中 skin:enbale 进行标记的 及动态 dynamicAddSkinEnableView(...)添加的需要换肤的 View 的集合, 这样整个换肤的过程就完成了.
整体换肤框架类图:
img
换肤架构类图. PNG
如何制作皮肤包:
1). 新建工程 project 2). 将换肤的资源文件添加到 res 文件下, 无 java 文件 3). 直接运行 build.gradle, 生成 apk 文件(注意, 运行时 Run/Redebug configurations 中 Launch Options 选择 launch nothing), 否则 build 会报 no default Activty 的错误. 4). 将 apk 文件重命名如 black.apk, 重命名为 black.skin 防止用户点击安装
在线换肤:
将皮肤包上传到服务器后台
客户端根据接口数据下载皮肤包, 进行加载及客户端换肤操作
结语:
至此, 整个换肤流程的原理解析已经全部讲完了. 本文针对基本的换肤原理流程做了解析, 初步建立了一套相对完善的换肤框架. 但是如何建立一套更加完善更加对其他开发者友善的换肤机制仍然是可以继续研究的方向. 比如如何更加安全的换肤, 如何对代码的侵入性做到最小 (比如通过在配置文件中配置需要换肤的 View 的 id name 而不是通过在 xml 文件中进行标记) 等等, 都是可以继续研究的方向, 以后有时间会继续在这方面进行探索.
因时间关系文章难免有疏漏, 欢迎提出指正, 谢谢. 同时对换肤感兴趣的童鞋可以参考以下链接:
1,Android-Skin-Loader 2,Android-skin-support 3,Android 主题换肤 无缝切换
来源: https://juejin.im/entry/5c008ecbf265da615064305a