浅试Android插件化换肤技术

1、背景

目标用户群体多元化、审美观参差不齐。默认界面效果并不能满足所有用户,提供换肤功能满足用户个性化需求。

2、技术实现

换肤就是改变Viwe样式,例如改变字体颜色、View背景等达到不同显示效果。

2.1 怎么从皮肤包加载资源?

Android资源是通过AssetManager 查找资源名称加载,大部分会用getResources().getXX(int resId)方法得到一个资源,实际上Resources通过ID查找到资源名称,再交给AssetManager加载,以下resources.arsc是资源映射表,Id name都有对应关系。

resources.arsc资源映射表

备注:皮肤包是一个只包含资源的APK。

AssetManager.java

/**
 * Add an additional set of assets to the asset manager.  This can be
 * either a directory or ZIP file.  Not for use by applications.  Returns
 * the cookie of the added asset, or 0 on failure.
 * {@hide}
 */
public final int addAssetPath(String path) {
    return  addAssetPathInternal(path, false);
}

复制代码

既然是AssetManager加载资源,那么从它入手找答案,AssetManager类中的 addAssetPath方法可以将一个ZIP压缩包添加到AssetManager,并加载这个ZIP资源。{@hide} 代表是隐藏API,不过可以通过反射调用。

反射调用addAssetPath 得到ZIP包的Resources。

public Resources loadResource() {
    try {
        Class<AssetManager> amClz = AssetManager.class;
        AssetManager assetManager = amClz.newInstance();
        Method method = amClz.getDeclaredMethod("addAssetPath", String.class);
        method.invoke(assetManager, "/mnt/shared/Other/app-debug.skin");
        return new Resources(assetManager, this.getResources().getDisplayMetrics(), this.getResources().getConfiguration());
    } catch (Exception e) {
    }
    return null;
}

访问资源

int resID = loadResource().getIdentifier("app_name", "string", "com.gonghuiyuan.skinlib");
Log.i("vip", loadResource().getString(resID));

复制代码

2.2. 资源拿到后设置到View?

现在知道怎么从皮肤包加载资源,拿到之后又有新的问题,怎么知道哪些View需要换肤?这个倒好解决,给Veiw做标记,获取View属性skinEnable==true说明View需要换肤。接下来递归ViewGroup.getChildAt(),拿到所有带标记的View,逐一设置TextColor,background等属性。但是这样效率低。假设LayoutInflater 加载XML到一个Activity这个过程中,这个时候将带标记的View保存起来,就不用遍历。

标记

<TextView
        skin:skinEnable"true"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello World!"  />
复制代码

LayoutInflater有个LayoutInflater.Factory接口,意思是渲染View的时候可以Hook,可以自定义标签名称,但不要用系统名称。

LayoutInflater.Factory.java

public interface Factory {
        /**
         * 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.
         * 
         * <p>
         * Note that it is good practice to prefix these custom names with your
         * package (i.e., com.coolcompany.apps) to avoid conflicts with system
         * names.
         * 
         * @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(String name, Context context, AttributeSet attrs);
    }
复制代码

举个栗子:

MainActivity.java代码

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        LayoutInflaterCompat.setFactory(LayoutInflater.from(this), new LayoutInflaterFactory() {
            @Override
            public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
                Log.e("vip", "name = " + name);
                int n = attrs.getAttributeCount();
                for (int i = 0; i < n; i++) {
                    Log.e("vip", attrs.getAttributeName(i) + " , " + attrs.getAttributeValue(i));
                }

                AppCompatDelegate delegate = getDelegate();
                View view = delegate.createView(parent, name, context, attrs);
                return view;
            }
        });

        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    }
}


布局文件activity_main

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="tv1" />

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="tv2" />

</LinearLayout>

	

复制代码
说明 白色框框部分,就是那两个Textview信息,这个例子说明每创建一个View都会走onCreateView方法。Hook的意思是说不用修改XML文件,就可以将Textview改为ImageView等其它控件。
效果图
Hook

3、动手撸个Demo

直接贴出代码,用到的都是上面两个例子。注意: 皮肤包的资源名称要求跟宿主的一致。这里就不出效果图 只是改个背景色。当然只是个Demo,如果要接入到正式项目,还是有很多问题要处理的。

public class MainActivity extends AppCompatActivity {
    private List<AttrsBean> attrsLists = new ArrayList<>();


    @Override
    protected void onCreate(Bundle savedInstanceState) {

        //记录要换肤的View及属性
        LayoutInflaterCompat.setFactory(LayoutInflater.from(this), new LayoutInflaterFactory() {
            @Override
            public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
                AppCompatDelegate delegate = getDelegate();
                View view = delegate.createView(parent, name, context, attrs);

                //指定命名空间的属性
                boolean skinEnable = attrs.getAttributeBooleanValue("http://schemas.android.com/apk/skin", "skinEnable", false);

                if (skinEnable) {//找到需要换肤的View,假设我们只支持Background
                    int n = attrs.getAttributeCount();
                    if(view==null){
                        try {
                            view = LayoutInflater.from(context).createView(name, null, attrs);
                        } catch (ClassNotFoundException e) {
                            e.printStackTrace();
                        }
                    }
                    for (int i = 0; i < n; i++) {
                        String attrName = attrs.getAttributeName(i);//属性名
                        String attrValue = attrs.getAttributeValue(i);//属性值
                        if ("background".equals(attrName)) {
                            String resName = context.getResources().getResourceEntryName(Integer.parseInt(attrValue.replace("@", "")));
                            AttrsBean attrsBean = new AttrsBean(view, new AttrsBean.BackgroudAttr(), resName);
                          attrsLists.add(attrsBean);
                        }
                    }
            }
                return view;
            }
        });




        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        AttrsBean.application = getApplication();



        findViewById(R.id.button).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                String path = "/mnt/shared/Other/skin_a.skin";
                String packageName = "com.gonghuiyuan.skinlib";

                AttrsBean.loadResource(MainActivity.this, path, packageName);

                for (AttrsBean attr : attrsLists) {
                    attr.getSkinAttr().apply(attr);
                }
            }
        });
    }



}

复制代码
public class AttrsBean {
    private View veiw;
    private SkinAttr skinAttr;
    private String attrName;

    public View getVeiw() {
        return veiw;
    }

    public void setVeiw(View veiw) {
        this.veiw = veiw;
    }

    public SkinAttr getSkinAttr() {
        return skinAttr;
    }

    public void setSkinAttr(SkinAttr skinAttr) {
        this.skinAttr = skinAttr;
    }

    public String getAttrName() {
        return attrName;
    }

    public void setAttrName(String attrName) {
        this.attrName = attrName;
    }

    public AttrsBean(View veiw, SkinAttr skinAttr, String attrName) {
        this.veiw = veiw;
        this.skinAttr = skinAttr;
        this.attrName = attrName;
    }






    public static abstract class SkinAttr {

        abstract void apply(AttrsBean attrsBean);
    }

    public static class TextColorAttr extends SkinAttr {

        @Override
        void apply(AttrsBean attrsBean) {
            if (attrsBean.getVeiw() instanceof TextView) {
                TextView tv = (TextView) attrsBean.getVeiw();
                tv.setTextColor(getColor(attrsBean.getAttrName()));
            }
        }
    }

    public static class BackgroudAttr extends SkinAttr {

        @Override
        void apply(AttrsBean attrsBean) {
            attrsBean.getVeiw().setBackgroundColor(getColor(attrsBean.getAttrName()));
        }
    }






    private static Map<String, Resources> resourcesMap = new HashMap<>();
    public static Application application;
    public static Resources mResources;
    public static String packageName;


    public static int getColor(String attrName) {
        return mResources.getColor(mResources.getIdentifier(attrName, "color", packageName));
    }


    //加载Resource
    public static void loadResource(Context context, String path, String packageName) {
        if (packageName == null) {
            packageName = context.getPackageName();
        }
        AttrsBean.packageName = packageName;

        if (path == null) {
            mResources = context.getResources();
            return;
        }

        if (resourcesMap.get(path) != null) {
            mResources = resourcesMap.get(path);
            return;
        }

        try {
            Class<AssetManager> amClz = AssetManager.class;
            AssetManager assetManager = amClz.newInstance();
            Method method = amClz.getDeclaredMethod("addAssetPath", String.class);
            method.invoke(assetManager, path);
            Resources resources = new Resources(assetManager, context.getResources().getDisplayMetrics(), context.getResources().getConfiguration());
            resourcesMap.put(path, resources);
            mResources = resources;
        } catch (Exception e) {
            Log.e("vip", e.toString());
        }
    }
}





复制代码

Layout文件

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:skin="http://schemas.android.com/apk/skin"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/colorAccent"
    android:orientation="vertical"
    skin:skinEnable="true">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="tv1"
        android:textColor="@color/colorPrimary"
        skin:skinEnable="true" />

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="tv2"
        android:textColor="@color/colorPrimary"
        skin:skinEnable="true" />

    <Button
        android:id="@+id/button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="更换" />
</LinearLayout>

复制代码
4、最后说点

其它换肤方案: 手Q黑科技:http://blog.youkuaiyun.com/cc_want/article/details/52510624 。 上面例子看完,建议亲自看Android-Skin-Loader源码,我也是从这个框架学习的。

参考资料

  1. 罗升阳>Android应用程序资源的编译和打包过程分析 http://blog.youkuaiyun.com/luoshengyang/article/details/8744683
  2. 任玉刚> Android源码分析-资源加载机制 http://blog.youkuaiyun.com/singwhatiwanna/article/details/24532419
  3. 承香墨影> 听说 Android 9.0 要禁用 @Hide Api 的调用,你怎么看? https://www.cnblogs.com/plokmju/p/8334869.html
  4. Android-Skin-Loader github https://github.com/fengjundev/Android-Skin-Loader
  5. Square开源交流 QQ群:166354503
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值