Android 换肤/主题切换

4,599 阅读6分钟

1、Android换肤的背景

移动客户端在不重启应用的情况下,实现动态换肤的效果。换肤这块做的比较好的,有网易云音乐,qq等,给用户带来了多样的界面选择和个性化定制。本文介绍Android客户端换肤/主题切换的原理和实践。

2、Android换肤的实现原理和实践方法

2.1 Android 实现应用内换肤的常用方式(两种)

  • 通过Theme切换主题,即静态方法。
  • 通过AssetManager切换主题,可实现动态切换。

2.2 动态换肤方案的优点分析

  • 动态换肤可以满足日常产品和运营需求,满足用户个性化界面定制的需求等等。
  • 动态换肤,相比于静态皮肤,可以减小apk大小。
  • 皮肤模块独立便于维护。
  • 由服务器下发,不需要发版即可实现动态更新。

2.3 通过Theme切换主题

Android 通过在 Activity 中使用 setTheme() 函数来设置背景样式,通过加载styles.xml里的样式来设置Android 应用的主题。需要在 setContentView(R.layout.activity_main);之前调用setTheme()

在开始制作主题之前我们先看下这张图

image.png

通过这张图我们可以了解到不同的字段代表的是哪一块的颜色,例如:

  • colorPrimary 代表的是 App Bar 的颜色。
  • colorPrimaryDark 代表的是状态栏的背景色。

我们也可以自己定制布局控件的颜色:

2.3.1 在values文件夹下创建attr.xml ,在attr.xml写入属性名

<resources>
    <attr name="mainColor" format="color" />
    <attr name="view1color" format="color" />
    <attr name="view2color" format="color" />
    <attr name="view3color" format="color" />
    <attr name="button1color" format="color" />
</resources>

2.3.2 在colors.xml 填入需要用到的颜色

<resources>

    <color name="colorPrimary">#008577</color>
    <color name="colorAccent">#D81B60</color>
    <color name="colorPrimaryDark">#00574B</color>
    <color name="blue2">#006c93</color>
    <color name="blue1">#b4e1f1</color>
    <color name="blue3">#003CFF</color>
    <color name="blace">#000000</color>
    <color name="white">#FFFFFF</color>
    <color name="red">#fd0000</color>
    <color name="red2">#f96363</color>
    <color name="green">#04fd00</color>
    <color name="yellow">#D9B300</color>
    <color name="gray">#cecece</color>
    <color name="pink">#ff3542</color>
</resources>

2.3.3 设置控件的颜色样式,注意红框圈起来的部分

image.png

2.3.4  在styles.xml文件下自定义主题样式,

    <style name="AppTheme" parent="Base.Theme.AppCompat.Light.DarkActionBar" >
        <!-- Customize your theme here. -->
        <item name="colorPrimary">@color/colorPrimary</item>// App Bar 颜色
        <item name="colorPrimaryDark">@color/colorPrimaryDark</item>//状态栏颜色
        <item name="colorAccent">@color/colorAccent</item>//控件选中状态下的颜色
        <item name="android:windowBackground">@drawable/white</item>//窗口背景颜色
        <item name="view1color">@color/blue1</item>//textview1的颜色
        <item name="view2color">@color/red</item>//textview2的颜色
        <item name="view3color">@color/yellow</item>//textview3的颜色
        <item name="button1color">@color/blue1</item>//button的颜色
    </style>

2.3.5 实现读取配置文件设置主题

    private  void setBaseTheme() {
        SharedPreferences sharedPreferences = getSharedPreferences(
                "com.example.test_preferences", MODE_PRIVATE);
        String themeType = sharedPreferences.getString("theme_type", "蓝色主题");
        int themeId;
        switch (themeType) {
            case "蓝色主题":
                themeId = R.style.blueTheme;
                break;
            case "粉色主题":
                themeId = R.style.pinkTheme;
                break;
            case "彩色主题":
                themeId = R.style.AppTheme;
                break;
            default:
                themeId = R.style.blueTheme;
        }
        setTheme(themeId);
    }

2.4 动态切换主题

2.4.1 原理

动态主题切换、换肤功能,是点击换肤按钮后,瞬间发生改变,系统在换肤时直接触发 View 的 setColor,setBackgroudColor,setDrawable 等方法实现,然而一个个方法进行调用显然是不现实的,对于这种公用的功能需求一般都会抽取成接口,让 View 继承实现自己的逻辑。

实现思路:

  1. 定义换肤功能接口,让需要换肤的 View 实现自己的换肤逻辑。
  2. 给 LayoutInflater 设置自定义的 Factory2,将 XML 中的 View 改为实现换肤接口的 View,并且将 View 记录下来。
  3. 制作皮肤包,这里让皮肤包和 App 本身资源名相同,值不同,这样换肤时,根据 View 设置的资源名去皮肤包中找同名资源。

如图所示,当打包出 Apk 后,保持资源名称相同,值不同:

image.png

  1. 换肤时,循环记录下来的换肤 View,调用其换肤方法即可。

2.4.2 动态换肤、主题切换实现流程

1、制作皮肤包的方法和过程

1). 使用Android studio新建工程project。

2). 将换肤的资源文件添加到res文件下,无java文件。

3). 直接运行build.gradle,生成apk文件。运行时Run/Redebug configurations 中Launch Options选择launch nothing,否则build 会报 no default Activty的错误。

4). 将apk文件重命名如themeXXX.apk,重命名为 themeXXX.skin 防止用户点击安装。

构建之后的产物提供给服务端,用于皮肤资源下载。

2、下载并加载皮肤包

1) 将皮肤包上传到服务器后台。

2) 客户端根据接口数据下载皮肤包,在客户端进行加载及客户端换肤操作。

3、拿到皮肤包Resource对象

// 构建PackageManager
PackageManager pm = context.getPackageManager();
PackageInfo info = pm.getPackageArchiveInfo(skinPkgPath, PackageManager.GET_ACTIVITIES);
skinPackageName = info.packageName;

// 反射方式把资源包注入AssetManager
AssetManager assetManager = AssetManager.class.newInstance();
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
addAssetPath.invoke(assetManager, skinPkgPath);

// 返回Resources
Resources superRes = context.getResources();
Resources skinResource = new 
Resources(assetManager,superRes.getDisplayMetrics(),superRes.getConfiguration());

superRes为当前app的Resource对象,而skinResource即为加载后的皮肤包的Resource对象。
皮肤包的资源即可通过

skinResource.getIdentifier(resName,"color",skinPackageName);

这种方式拿到了。

4、标记需要换肤的View

通过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进行的。这个框架主要是动态加载皮肤包,在不需要重启应用的前提下,实现对页面布局等动态换肤的操作。皮肤包独立制作和维护,不和主工程产生耦合。同时由后台服务器下发,可即时在线更新不依赖客户端版本。

5、切换时即时刷新页面

1、SkinBaseApplication:

public class SkinApplication extends BaseApplication {

@Override
public void onCreate() {
    super.onCreate();
    SkinManager.getInstance().init(this);
    SkinManager.getInstance().load();
  }
}

主要是进行一些初始化的操作。

2、SkinBaseActivity:
public abstract class BaseActivity extends
    code.solution.base.BaseActivity implements ISkinUpdateIDynamicNewView {

private SkinInflaterFactory mSkinInflaterFactory;

@Override
protected 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);
}

@Override
public void onThemeUpdate() {
    if(!isResponseOnSkinChanging){
        return;
    }
    mSkinInflaterFactory.applySkin();
    changeStatusColor();
}

在这里使用了之前自定义的SkinInflaterFactory,来替换默认的Factory,以达到截获创建View,获取View的属性,与支持换肤的属性进行对比,进行View换肤操作以及保存这些需要换肤的View到List中,在下次换肤切换时对这些View进行换肤的目的。

其中换肤操作执行时,会调用SKinManager.notifySKinUpdate方法

@Override
public 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的集合,这样整个换肤的过程就完成了。

3、参考:

blog.csdn.net/fjnu_se/art…

developer.aliyun.com/article/687…

github.com/fengjundev/…

github.com/ximsfei/And…