Android动态换肤

现在的很多应用都有换肤的功能,例如QQ。这类应用都是在线下载皮肤包,然后在不重启的情况下直接完成换肤

示例

demonstrate.gif

原理

  1. Activity setContentView内部调用

关于setContentView的所有方法,这里调用了getWindow()返回了Window,这个Window在activity的attach方法中被赋值为PhoneWindow
Activity.java源码:


    public void setContentView(@LayoutRes int layoutResID) {
        getWindow().setContentView(layoutResID);
        initWindowDecorActionBar();
    }

    public void setContentView(View view) {
        getWindow().setContentView(view);
        initWindowDecorActionBar();
    }
    
    public void setContentView(View view, ViewGroup.LayoutParams params) {
        getWindow().setContentView(view, params);
        initWindowDecorActionBar();
    }
    
    final void attach(Context context, ActivityThread aThread,
            Instrumentation instr, IBinder token, int ident,
            Application application, Intent intent, ActivityInfo info,
            CharSequence title, Activity parent, String id,
            NonConfigurationInstances lastNonConfigurationInstances,
            Configuration config, String referrer, IVoiceInteractor voiceInteractor,
            Window window, ActivityConfigCallback activityConfigCallback) {
      ...
        mWindow = new PhoneWindow(this, window, activityConfigCallback);
      ...
    }

  1. PhoneWindow setContentView内部调用

可以看到实际调用了LayoutInflater.inflate方法
PhoneWindow.java源码:

 @Override
    public void setContentView(int layoutResID) {
        if (mContentParent == null) {
            installDecor();
        } else if (!hasFeature(FEATURE_CONTENT_TRANSITIONS)) {
            mContentParent.removeAllViews();
        }
        if (hasFeature(FEATURE_CONTENT_TRANSITIONS)) {
            final Scene newScene = Scene.getSceneForLayout(mContentParent, layoutResID,
                    getContext());
            transitionTo(newScene);
        } else {
            mLayoutInflater.inflate(layoutResID, mContentParent);
        }
        mContentParent.requestApplyInsets();
        final Callback cb = getCallback();
        if (cb != null && !isDestroyed()) {
            cb.onContentChanged();
        }
        mContentParentExplicitlySet = true;
    }

    @Override
    public void setContentView(View view) {
        setContentView(view, new ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT));
    }

    @Override
    public void setContentView(View view, ViewGroup.LayoutParams params) {
        // Note: FEATURE_CONTENT_TRANSITIONS may be set in the process of installing the window
        // decor, when theme attributes and the like are crystalized. Do not check the feature
        // before this happens.
        if (mContentParent == null) {
            installDecor();
        } else if (!hasFeature(FEATURE_CONTENT_TRANSITIONS)) {
            mContentParent.removeAllViews();
        }

        if (hasFeature(FEATURE_CONTENT_TRANSITIONS)) {
            view.setLayoutParams(params);
            final Scene newScene = new Scene(mContentParent, view);
            transitionTo(newScene);
        } else {
            mContentParent.addView(view, params);
        }
        mContentParent.requestApplyInsets();
        final Callback cb = getCallback();
        if (cb != null && !isDestroyed()) {
            cb.onContentChanged();
        }
        mContentParentExplicitlySet = true;
    }
  1. LayoutInflater.inflate内部调用

由源码可知,view由Factory2和Factory创建,如果我们hook了Factory2那不是视图的创建可以由我们说了算
LayoutInflater.java源码:

    public View inflate(@LayoutRes int resource, @Nullable ViewGroup root) {
        return inflate(resource, root, root != null);
    }
    
    public View inflate(@LayoutRes int resource, @Nullable ViewGroup root, boolean attachToRoot) {
        final Resources res = getContext().getResources();
        if (DEBUG) {
            Log.d(TAG, "INFLATING from resource: \"" + res.getResourceName(resource) + "\" ("
                    + Integer.toHexString(resource) + ")");
        }

        final XmlResourceParser parser = res.getLayout(resource);
        try {
            return inflate(parser, root, attachToRoot);
        } finally {
            parser.close();
        }
    }
    
    public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
        synchronized (mConstructorArgs) {
            ...
            final View temp = createViewFromTag(root, name, inflaterContext, attrs);
            result = temp;
            return result;
            ...
        }
    }
    
     View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
            boolean ignoreThemeAttr) {
        ...
            View view;
            if (mFactory2 != null) {
                view = mFactory2.onCreateView(parent, name, context, attrs);
            } else if (mFactory != null) {
                view = mFactory.onCreateView(name, context, attrs);
            } else {
                view = null;
            }
        ...
            return view;
    }
    
    public void setFactory2(Factory2 factory) {
    //由此处可知设置Factory2只能设置一次,所以我们设置时需要将mFactorySet改成false
        if (mFactorySet) {
            throw new IllegalStateException("A factory has already been set on this LayoutInflater");
        }
        if (factory == null) {
            throw new NullPointerException("Given factory can not be null");
        }
        mFactorySet = true;
        if (mFactory == null) {
            mFactory = mFactory2 = factory;
        } else {
            mFactory = mFactory2 = new FactoryMerger(factory, factory, mFactory, mFactory2);
        }
    }
  1. Factory2

LayoutInflater.java源码:

public interface Factory2 extends Factory {
        public View onCreateView(View parent, String name, Context context, AttributeSet attrs);
    }

可以看到参数里面有AttributeSet,我们可以通过AttributeSet筛选需要做处理的属性,记录view和对应的属性,然后在换肤时替换属性对应的资源,就可以达到换肤的目的了,
具体处理逻辑较为复杂,可以通过后面提供的源码查看

SkinPeeler库

库代码传送门

SkinPeeler库是基于上面的原理完成的换皮库,使用方法:

  1. 导入库
//root build.gradle
allprojects {
    repositories {
        ...
        maven { url 'https://www.jitpack.io' }
    }
}

//app build.gradle
dependencies {
    implementation 'com.github.ray-tianfeng:skin-peeler:v1.0.0'
}
  1. 使用
  • 换肤 SkinPeeler.getInstance().skin(String skinPath);
    传入制作好的皮肤包,即可完成换肤

  • 还原 SkinPeeler.getInstance().restore();
    不使用皮肤

  • 换肤监听 SkinPeeler.getInstance().addSkinChangeListener(Activity
    mActivity, SkinPeeler.OnSkinChangeListener mOnSkinChangeListener);

    皮肤切换监听,完成换皮时回调

  • 自定义属性适配器

    1. 实现BaseAttrADT.java
    //支持的属性集合,例如:background、src、textColor
    public List<String> getAttrName();   
    /**
    * 应用皮肤
    * @param targetView 目标视图
    * @param skinResources 皮肤Resources
    * @param skinPackageName 皮肤包包名
    * @param attrName 属性名称
    * @param oldValueName 旧值方便通过{@link com.zlong.skinpeeler.utils.IdUtils} 查找皮肤包资源属性和名称
    */
    public void applySkin(View targetView, Resources skinResources, String skinPackageName, String attrName, String oldValueName) throws Exception;
    
    /**
    * 恢复原始皮肤
    * @param targetView 目标视图
    * @param resources 原始 Resources
    * @param attrName 属性名称
    * @param oldValueName 旧值
    */
    public void restore(View targetView, Resources resources, String attrName, String oldValueName);
    
    1. 添加属适配器至管理器SkinPeeler.getInstance().addAttrADT(BaseAttrADT attrADT)
    2. 常用工具类
      IdUtils:资源Id查找工具类,通过IdUtils.findResById(int id),查找原包中id对应的类型、名称
  • 自定义属性注意事项

    1. ID
      原包中R.xx.xx对应的资源id不可在皮肤包中使用,必须使用皮肤包中对应资源的id,因为原包中的资源对应的id,和皮肤包中同一资源对应的id不同
    2. 资源查找
      applySkin提供了皮肤包的Resources,那我们可以通过皮肤包资源id获取对应的资源,
      我们把原包中的资源id通过IdUtils.findResById查找资源对应的名称和类型,然后通过Resources.getIdentifier(String name, String defType, String defPackage)查找资源在皮肤包中对应的id,最后获取资源就行了

通过第二步我们可以得到资源的id,但是我们不能直接把皮肤包的资源id直接设置到view上,因为原皮肤对应的Resources,肯定没有皮肤包对应的资源id。
在代码中也不能直接设置资源id,因为换肤后,直接设置资源id,系统直接通过原始Resources查找的资源。需要通过上面的资源查找,直接查找对应的资源,设置到对应的view上
库内置了AutoAttrADT.java可以对照着来实现自定义属性

  • 实现属性
    库已经通过AutoAttrADT.java实现了常用属性的适配
    background、src、textColor、drawableLeft、drawableTop、drawableRight、drawableBottom

  • 皮肤包制作

    1. 创建Module
    2. 将apply plugin: 'com.android.library'修改为apply plugin:
      'com.android.application',因为这样可以生成对应的资源id
    3. 将原项目中res目录下的所有资源复制到皮肤包中,layout可以在完成制作后删除
    4. 替换换肤时需要修改的资源
    5. 通过build->build bundles->build apk将皮肤包打包
    6. 在对应module的build/outputs/debug
      下有一个打包好的皮肤包apk,可以将后缀修改skin,或者直接使用。修改后缀为了防止用户安装和删除。
  • 库使用注意事项

    • 需要文件读取权限,如果在6.0及以上,需要做权限处理
    • 包名只能是androidManifest中的packageName,不能在gradle使用applicationId,因为IdUtils通过包名查找R类的。
    • 所有的资源尽量先定义后使用(R.string.xx, R.color.xx, R.drawable.xx)
    • 沉浸式菜单栏适配,先定义菜单栏颜色,然后在String中定义图标显示模式,设置监听。皮肤变化时,在回调中更改状态栏颜色及图标颜色

扩展1

在上面的实现过程中有使用到AttributeSet,这个就是当前view的属性集合,我们是不是可以自定义一个属性(圆角背景)。然后在onCreateView解析到此属性时,
通过java代码创建一个drawable,设置给view,注意此处自定义的属性只能在xml中使用,因为View不包含这个自定义的属性的。!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,491评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,856评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,745评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,196评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,073评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,112评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,531评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,215评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,485评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,578评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,356评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,215评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,583评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,898评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,174评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,497评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,697评论 2 335