Android动态换肤原理解析及实践

简介: 前言:本文主要讲述如何在项目中,在不重启应用的情况下,实现动态换肤的效果。换肤这块做的比较好的,有网易云音乐,qq等,给用户带来了多样的界面选择和个性化定制。

前言:

本文主要讲述如何在项目中,在不重启应用的情况下,实现动态换肤的效果。换肤这块做的比较好的,有网易云音乐,qq等,给用户带来了多样的界面选择和个性化定制。之前看到换肤的效果后对这块也比较好奇,就抽时间研究了下,今天给大家分享解析原理和实践中遇到的问题。

为什么要做动态换肤:

  • 动态换肤可以满足日常产品和运营需求,满足用户个性化界面定制的需求等等。

  • 动态换肤,相比于静态皮肤,可以减小apk大小

  • 皮肤模块独立便于维护

  • 由服务器下发,不需要发版即可实现动态更新

换肤的一般实现思路:

  • 资源打包静态替换方案:
    指定资源路径地址,在打包时将对应资源打包进去
    build.gradle中进行对应配置

    sourceSets {
    // 测试版本和线上版本用同一套资源
    YymTest {
        res.srcDirs = ["src/Yym/res", "src/YymTest/res"]
        assets.srcDirs = ["src/Yym/assets"]
     }
    }
    

    这种方式是在打包时,通过指定资源文件的路径在编译打包时将对应的资源打包进去,以实现不同的主题样式等换肤需求。适合发布马甲版本的app需求。

  • 动态换肤方案:
    应用运行时,选择皮肤后,在主app中拿到对应皮肤包的Resource,将皮肤包中的
    资源动态加载到应用中展示并呈现给用户。

动态换肤的一般步骤为:

  1. 下载并加载皮肤包

  2. 拿到皮肤包Resource对象

  3. 标记需要换肤的View

  4. 切换时即时刷新页面

  5. 制作皮肤包

  6. 换肤整体框架的搭建

如何拿到皮肤包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>();
@Override
public 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);
}

@Override
public void attach(ISkinUpdate observer) {
    if(skinObservers == null){
        skinObservers = new ArrayList<ISkinUpdate>();
    }
    if(!skinObservers.contains(observer)){
        skinObservers.add(observer);
    }
}

@Override
public void detach(ISkinUpdate observer) {
    if(skinObservers == null) return;
    if(skinObservers.contains(observer)){
        skinObservers.remove(observer);
    }
}

@Override
public 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 {

@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 ISkinUpdate, IDynamicNewView {

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

整体换肤框架类图:

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防止用户点击安装

在线换肤:

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

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

结语:

至此,整个换肤流程的原理解析已经全部讲完了。本文针对基本的换肤原理流程做了解析,初步建立了一套相对完善的换肤框架。但是如何建立一套更加完善更加对其他开发者友善的换肤机制仍然是可以继续研究的方向。比如如何更加安全的换肤,如何对代码的侵入性做到最小(比如通过在配置文件中配置需要换肤的View的id name 而不是通过在xml文件中进行标记)等等,都是可以继续研究的方向,以后有时间会继续在这方面进行探索。

因时间关系文章难免有疏漏,欢迎提出指正,谢谢。同时对换肤感兴趣的童鞋可以参考以下链接:

1、Android-Skin-Loader
2、Android-skin-support
3、Android主题换肤 无缝切换

 

更多Android进阶技术,面试资料系统整理分享,职业生涯规划,产品,思维,行业观察,谈天说地。可以加Android架构师群;701740775。

相关文章
|
7月前
|
数据采集 监控 API
告别手动埋点!Android 无侵入式数据采集方案深度解析
传统的Android应用监控方案需要开发者在代码中手动添加埋点,不仅侵入性强、工作量大,还难以维护。本文深入探讨了基于字节码插桩技术的无侵入式数据采集方案,通过Gradle插件 + AGP API + ASM的技术组合,实现对应用性能、用户行为、网络请求等全方位监控,真正做到零侵入、易集成、高稳定。
847 86
|
8月前
|
存储 消息中间件 人工智能
【05】AI辅助编程完整的安卓二次商业实战-消息页面媒体对象(Media Object)布局实战调整-按钮样式调整实践-优雅草伊凡
【05】AI辅助编程完整的安卓二次商业实战-消息页面媒体对象(Media Object)布局实战调整-按钮样式调整实践-优雅草伊凡
248 11
【05】AI辅助编程完整的安卓二次商业实战-消息页面媒体对象(Media Object)布局实战调整-按钮样式调整实践-优雅草伊凡
|
11月前
|
安全 Java Android开发
为什么大厂要求安卓开发者掌握Kotlin和Jetpack?深度解析现代Android开发生态优雅草卓伊凡
为什么大厂要求安卓开发者掌握Kotlin和Jetpack?深度解析现代Android开发生态优雅草卓伊凡
468 0
为什么大厂要求安卓开发者掌握Kotlin和Jetpack?深度解析现代Android开发生态优雅草卓伊凡
|
机器学习/深度学习 数据可视化 PyTorch
深入解析图神经网络注意力机制:数学原理与可视化实现
本文深入解析了图神经网络(GNNs)中自注意力机制的内部运作原理,通过可视化和数学推导揭示其工作机制。文章采用“位置-转移图”概念框架,并使用NumPy实现代码示例,逐步拆解自注意力层的计算过程。文中详细展示了从节点特征矩阵、邻接矩阵到生成注意力权重的具体步骤,并通过四个类(GAL1至GAL4)模拟了整个计算流程。最终,结合实际PyTorch Geometric库中的代码,对比分析了核心逻辑,为理解GNN自注意力机制提供了清晰的学习路径。
883 7
深入解析图神经网络注意力机制:数学原理与可视化实现
|
机器学习/深度学习 缓存 自然语言处理
深入解析Tiktokenizer:大语言模型中核心分词技术的原理与架构
Tiktokenizer 是一款现代分词工具,旨在高效、智能地将文本转换为机器可处理的离散单元(token)。它不仅超越了传统的空格分割和正则表达式匹配方法,还结合了上下文感知能力,适应复杂语言结构。Tiktokenizer 的核心特性包括自适应 token 分割、高效编码能力和出色的可扩展性,使其适用于从聊天机器人到大规模文本分析等多种应用场景。通过模块化设计,Tiktokenizer 确保了代码的可重用性和维护性,并在分词精度、处理效率和灵活性方面表现出色。此外,它支持多语言处理、表情符号识别和领域特定文本处理,能够应对各种复杂的文本输入需求。
1579 6
深入解析Tiktokenizer:大语言模型中核心分词技术的原理与架构
|
XML JavaScript Android开发
【Android】网络技术知识总结之WebView,HttpURLConnection,OKHttp,XML的pull解析方式
本文总结了Android中几种常用的网络技术,包括WebView、HttpURLConnection、OKHttp和XML的Pull解析方式。每种技术都有其独特的特点和适用场景。理解并熟练运用这些技术,可以帮助开发者构建高效、可靠的网络应用程序。通过示例代码和详细解释,本文为开发者提供了实用的参考和指导。
546 15
|
监控 Shell Linux
Android调试终极指南:ADB安装+多设备连接+ANR日志抓取全流程解析,覆盖环境变量配置/多设备调试/ANR日志分析全流程,附Win/Mac/Linux三平台解决方案
ADB(Android Debug Bridge)是安卓开发中的重要工具,用于连接电脑与安卓设备,实现文件传输、应用管理、日志抓取等功能。本文介绍了 ADB 的基本概念、安装配置及常用命令。包括:1) 基本命令如 `adb version` 和 `adb devices`;2) 权限操作如 `adb root` 和 `adb shell`;3) APK 操作如安装、卸载应用;4) 文件传输如 `adb push` 和 `adb pull`;5) 日志记录如 `adb logcat`;6) 系统信息获取如屏幕截图和录屏。通过这些功能,用户可高效调试和管理安卓设备。
|
传感器 人工智能 监控
反向寻车系统怎么做?基本原理与系统组成解析
本文通过反向寻车系统的核心组成部分与技术分析,阐述反向寻车系统的工作原理,适用于适用于商场停车场、医院停车场及火车站停车场等。如需获取智慧停车场反向寻车技术方案前往文章最下方获取,如有项目合作及技术交流欢迎私信作者。
1049 2
|
缓存 边缘计算 安全
阿里云CDN:全球加速网络的实践创新与价值解析
在数字化浪潮下,用户体验成为企业竞争力的核心。阿里云CDN凭借技术创新与全球化布局,提供高效稳定的加速解决方案。其三层优化体系(智能调度、缓存策略、安全防护)确保低延迟和高命中率,覆盖2800+全球节点,支持电商、教育、游戏等行业,帮助企业节省带宽成本,提升加载速度和安全性。未来,阿里云CDN将继续引领内容分发的行业标准。
747 7

热门文章

最新文章

推荐镜像

更多