Android手游SDK换肤方案

avatar

作者

大家好,我叫🐜;

目前主要负责国内相关业务开发和一些日常业务。

背景

最近SDK业务做了游戏内嵌视频直播的功能,由于不同研发游戏的界面风格不一样,所以我们在SDK定好了换肤UI的标准,研发可以根据这套标准自行替换对应的皮肤UI资源,不需要修改SDK的内容,选择对应的换肤SDK版本出包即可实现皮肤替换。

方案调研

目前市面上换肤方案有Resource包装流和AssetManager替换流。

Resource包装流的原理大概如下:
1、创建新的Resrouce对象(代理的Resource)
2、替换系统Resource对象
3、运行时动态映射(原理相同资源在不同的资源表中的Type和Name一样)
4、xml布局解析拦截(xml布局中的资源不能通过代理Resource加载,LayoutInflater)
优势:

支持String/Layout

存在问题:

1.资源获取效率有影响

2.不支持style、asset目录

3.Resource多出替换,Resource包装类代码量大

资源重定向:

支持动态映射

AssetManager替换流的原理大概如下:
1、hook系统AssetMananger对象(系统资源路径及应用的资源路径 都添加到了AssetManager的Path当中)
2、编译期静态对齐(皮肤包中资源文件对应的id数值修改与应用程序中一致)
优势:

支持style、asset目录,替换AM实例简洁

存在问题:

强依赖编译器资源id

资源重定向:

不支持动态映射

方案选择

采取了Resource的LayoutInflater的方案,原因如下:

  1. 不需要维护皮肤包的apk,降低sdk的维护成本和研发的接入成本
  2. 需要支持资源动态映射
  3. 业务只需要支持图片替换

所以开发接入流程就是:

  1. 每个SDK版本定好换肤图片资源和名称输出到换肤说明文档上(Android和iOS共用)
  2. 接入方根据换肤说明文档上,出图切图,然后把切图资源放到规定的asset目录上面
  3. 母包选择对应的换肤SDK出包即可

原理分析

LayoutInflater分析

  1. LayoutInflater如何实例化

我们通常调用以下代码获得一个LayoutInflater 使用流程

LayoutInflater.from(context)

1.png

context一般传的是activity实例

2.png

没有找到inflater_service的name,再看看父类

3.png

关键点是getBaseContext()返回的实例,通过分析上下文可知,getBaseContext()返回的是ContextWrapper的成员变量,且在attachBaseContext方法传进,如下图所示

4.png

因为Activity是在ActivityThread中通过反射实例的,所以在ActivityThread找到如下代码

5.png

通过以上可知appContext这个参数的实例是ContextImpl

6.png

根据上图追踪,LayoutInflater的实例生成下图所示

7.png

  1. View如何通过LayoutInflater生成,如下图所示

解析xml生成View实例流程.png

通过对LayoutInflater这个类的分析,我们可以在蓝色部分设置factory的实例来拦截系统View生成,所以我们可以在factory对应实例的方法createView 通过类似于黄色部分的实现逻辑实例出View,再通过动态映射资源达到替换xml默认的资源

3.使用factory

所以我们使用LayoutInflater的setFactory、setFactory2这两个方法在对应的实例做我们业务的拦截处理即可,这两个方法的功能基本是一致的,setFactory2是在SDK>=11以后引入的,所以我们要根据SDK的版本去选择调用上述方法。 v4包下有个类LayoutInflaterCompat帮我们完成了兼容性的操作,提供的方法为:

LayoutInflaterCompat
- setFactory(LayoutInflater inflater, 
             LayoutInflaterFactory factory)

AppCompatActivity冲突处理

1.内部使用了LayoutInflater的setFactory2,流程大概如下所示;

AppCompatActivity兼容View处理逻辑.png

通过上面分析,其内部的逻辑跟我们换肤逻辑一样

2.异常处理

如果我们在AppCompatActivity onCreate()之后设置LayoutInflaterCompat.setFactory2会抛出一下异常

8.png

通过代码分析可知

9.png

LayoutInfalter调用过setFactory或者setFactory2的话,下一次调用的话就会跑出异常。

那么我们在AppCompatActivity onCreate()之前设置LayoutInflaterCompat.setFactory2呢?,通过源码分析可知

10.png

虽然这样不会跑出异常,但是就不会执行到AppCompatDelegateImpl的onCreateView方法,相当于AppCompatDelegateImpl设置的factory失效。所以我们需要在我们自己factory来做对AppCompatDelegateImpl的factory的处理。因为AppCompatDelegateImpl的下面的这个方法是public

11.png

所以我们可以在我们factory处理逻辑调用AppCompatDelegate调用下面方法来处理解决上面的情况

View view = delegate.createView(parent, name, context, attrs);

所以处理处理逻辑大概如下所示

LayoutInflaterCompat.setFactory(LayoutInflater.from(this), new LayoutInflaterFactory()
{
    @Override
    public View onCreateView(View parent, String name, Context context, AttributeSet attrs)
    {
        //你可以在这里直接new自定义View

        //你可以在这里将系统类替换为自定义View

         //appcompat 创建view代码
        AppCompatDelegate delegate = getDelegate();
        View view = delegate.createView(parent, name, context, attrs);

        return view;
    }
});

补充一点,因为我们的业务涉及到切包,我们无法确认游戏方的代码是否使用到了AppCompatActivity,同时涉及到support和androidx的兼容包,上面的

//appcompat 创建view代码
        AppCompatDelegate delegate = getDelegate();
        View view = delegate.createView(parent, name, context, attrs);

用了反射来处理这种兼容性问题。

所以处理方式应该是:

1、不考虑兼容AppCompatActivity:

只需要在Activity setContentView之前直接设置我们自定义的factory。

2、考虑兼容AppCompatActivity:

在AppCompatActivity onCreate()之前调用我们我们自定义的factory,然后在我们自定义的factory调用 AppCompatActivity的方法来兼容AppCompatActivity原来的处理。

xml->view实例的细节分析

  1. xml中的系统View不需要前缀能加载到?

因为在PhoneLayoutInflater中生命了一个字段

  private static final String[] sClassPrefixList = {
        "android.widget.",
        "android.webkit.",
        "android.app."
    };

然后在onCreateView(String name, AttributeSet attrs)中先加上前缀去加载,如下图所示

for (String prefix : sClassPrefixList) {
            try {
                View view = createView(name, prefix, attrs);
                if (view != null) {
                    return view;
                }
            } catch (ClassNotFoundException e) {
                // In this case we want to let the base class take a crack
                // at it.
            }
        }

        return super.onCreateView(name, attrs);

在父类中还有一个前缀是android.view.

protected View onCreateView(String name, AttributeSet attrs)
            throws ClassNotFoundException {
        return createView(name, "android.view.", attrs);
    }

2.自定义view为什么需要声明两个参数的构造方法 因为在LayoutInflater的createView(String name, String prefix, AttributeSet attrs)方法中,反射对应的Constructor是需要Context.class和AttributeSet.class的参数类型,否则会抛异常,关键代码


            Object lastContext = mConstructorArgs[0];
            if (mConstructorArgs[0] == null) {
                // Fill in the context if not already within inflation.
                mConstructorArgs[0] = mContext;
            }
            Object[] args = mConstructorArgs;
            args[1] = attrs;

            final View view = constructor.newInstance(args);

3.看源码过程中,view的parames是通过parentview的方法生成的,params = root.generateLayoutParams(attrs),然后通过解析xml的内容设置对应属性的值之前一直以为parames是在view本身的方法生成的,后来也了解了一下。一些屏幕适配方案,就是通过重写generateLayoutParams来处理。

开发业务流程

大概需要处理的业务如下

换肤开发流程图.png

1、在每个activity的oncreate方法调用SkinManager初始化方法。

2、SkinManager初始化方法注册我们需要换肤的View。

3、在我们自定义的Factory实现类oncreateview方法中模仿系统加载的机制来加载我们的换肤View和获取对应的资源属性和值。

4、根据获取到的资源值动态映射加载外部换肤资源,原理就是通过获取资源的id,得到资源对应的资源string,再通过string查到加载外部资源的路径。

最后

以上是针对LayoutInflater换肤方案落地的研究和思考,希望对大家有帮助,也欢迎一起讨论。