字节头条部Android二面:说一说Android动态换肤实现原理吧,答不上来下一个

2021-01-19 10:30:48 浏览数 (1)

换肤分为动态换肤和静态换肤

静态换肤

这种换肤的方式,也就是我们所说的内置换肤,就是在APP内部放置多套相同的资源。进行资源的切换。

这种换肤的方式有很多缺点,比如, 灵活性差,只能更换内置的资源、apk体积太大,在我们的应用Apk中等一般图片文件能占到apk大小的一半左右。

当然了,这种方式也并不是一无是处, 比如我们的应用内,只是普通的 日夜间模式 的切换,并不需要图片等的更换,只是更换颜色,那这样的方式就很实用。

动态换肤

适用于大量皮肤,用户选择下载,像QQ、网易云音乐这种。它是将皮肤包下载到本地,皮肤包其实是个APK。

换肤包括替换图片资源、布局颜色、字体、文字颜色、状态栏和导航栏颜色。

动态换肤步骤包括:

  • 采集需要换肤的控件
  • 加载皮肤包
  • 替换资源

实现原理

首先Activity的onCreate()方法里面我们都要去调用setContentView(int id) 来指定当前Activity的布局文件:

代码语言:javascript复制
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    }

再往里看:

代码语言:javascript复制
    @Override
    public void setContentView(int resId) {
        ensureSubDecor();
        ViewGroup contentParent = (ViewGroup) mSubDecor.findViewById(android.R.id.content);
        contentParent.removeAllViews();
        LayoutInflater.from(mContext).inflate(resId, contentParent);//这里实现view布局的加载
        mOriginalWindowCallback.onContentChanged();
    }
代码语言:javascript复制
    public View inflate(@LayoutRes int resource, @Nullable ViewGroup root) {
        return inflate(resource, root, root != null);
    }
代码语言:javascript复制
    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();
        }
    }
代码语言:javascript复制
 public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
            ...
            final String name = parser.getName();
            final View temp = createViewFromTag(root, name, inflaterContext, attrs);
            ...
            return temp;
    }

可以看到inflate会返回具体的View对象出去,那么我们的关注焦点就放在createViewFromTag中了

代码语言:javascript复制
    /**
     * Creates a view from a tag name using the supplied attribute set.
     * <p>
     * <strong>Note:</strong> Default visibility so the BridgeInflater can
     * override it.
     *
     * @param parent the parent view, used to inflate layout params
     * @param name the name of the XML tag used to define the view
     * @param context the inflation context for the view, typically the
     *                {@code parent} or base layout inflater context
     * @param attrs the attribute set for the XML tag used to define the view
     * @param ignoreThemeAttr {@code true} to ignore the {@code android:theme}
     *                        attribute (if set) for the view being inflated,
     *                        {@code false} otherwise
     */
    View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
            boolean ignoreThemeAttr) {
        try {
            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;
        } catch (Exception e) {
        }
    }

inflate最终调用了createViewFromTag方法来创建View,在这之中用到了factory,如果factory存在就用factory创建对象,如果不存在就由系统自己去创建。我们只需要实现我们的Factory然后设置给mFactory2就可以采集到所有的View了,这里是一个Hook点。

当我们采集完了需要换肤的view,下一步就是加载皮肤包资源。当我们拿到当前View的资源名称时就会先去皮肤插件中的资源文件里找

Android加载资源的流程图:

1.采集换肤控件

android解析xml创建view的步骤:

  • setContentView -> window.setContentView()(实现类是PhoneWindow)->mLayoutInflater.inflate() -> inflate … ->createViewFromTag().

所以我们复写了Factory的onCreateView之后,就可以不通过系统层而是自己截获从xml映射的View进行相关View创建的操作,包括对View的属性进行设置(比如背景色,字体大小,颜色等)以实现换肤的效果。如果onCreateView返回null的话,会将创建View的操作交给Activity默认实现的Factory的onCreateView处理。

1.使用ActivityLifecycleCallbacks,尽可能少的去侵入代码,在onActivityCreated中监听每个activity的创建。

代码语言:javascript复制
@Override
public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
       LayoutInflater layoutInflater = LayoutInflater.from(activity);
       try {
           //系统默认 LayoutInflater只能设置一次factory,所以利用反射解除限制
           Field mFactorySet = LayoutInflater.class.getDeclaredField("mFactorySet");
           mFactorySet.setAccessible(true);
           mFactorySet.setBoolean(layoutInflater, false);
       } catch (Exception e) {
           e.printStackTrace();
       }

       //添加自定义创建View 工厂
       SkinLayoutFactory factory = new SkinLayoutFactory(activity, skinTypeface);
       layoutInflater.setFactory2(factory);
}

2.在 SkinLayoutFactory中将每个创建的view进行筛选采集

代码语言:javascript复制
  //根据tag反射获取view
    @Override
    public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
        // 反射 classLoader
        View view = createViewFromTag(name, context, attrs);
        // 自定义View
        if(null ==  view){
            view = createView(name, context, attrs);
        }

        //筛选符合属性View
        skinAttribute.load(view, attrs);

        return view;
    }

3.将view封装成对象

代码语言:javascript复制
    //view的参数对象
    static class SkinPain {
        String attributeName;
        int resId;

        public SkinPain(String attributeName, int resId) {
            this.attributeName = attributeName;
            this.resId = resId;
        }
    }

    //view对象
     static class SkinView {
        View view;
        List<SkinPain> skinPains;

        public SkinView(View view, List<SkinPain> skinPains) {
            this.view = view;
            this.skinPains = skinPains;
        }
     }

将属性符合的view保存起来

代码语言:javascript复制
public class SkinAttribute {
    private static final List<String> mAttributes = new ArrayList<>();

    static {
        mAttributes.add("background");
        mAttributes.add("src");

        mAttributes.add("textColor");
        mAttributes.add("drawableLeft");
        mAttributes.add("drawableTop");
        mAttributes.add("drawableRight");
        mAttributes.add("drawableBottom");

        mAttributes.add("skinTypeface");
    }

    private List<SkinView> skinViews = new ArrayList<>();

    public void load(View view, AttributeSet attrs) {
        List<SkinPain> skinPains = new ArrayList<>();
        for (int i = 0; i < attrs.getAttributeCount(); i  ) {
            //获取属性名字
            String attributeName = attrs.getAttributeName(i);
            if (mAttributes.contains(attributeName)) {
                //获取属性对应的值
                String attributeValue = attrs.getAttributeValue(i);
                if (attributeValue.startsWith("#")) {
                    continue;
                }
                int resId;
                //判断前缀字符串 是否是"?"
                //attributeValue  = "?2130903043"
                if (attributeValue.startsWith("?")) {  //系统属性值
                    //字符串的子字符串  从下标 1 位置开始
                    int attrId = Integer.parseInt(attributeValue.substring(1));
                    resId = SkinThemeUtils.getResId(view.getContext(), new int[]{attrId})[0];
                } else {
                    //@1234564
                    resId = Integer.parseInt(attributeValue.substring(1));
                }
                if (resId != 0) {
                    SkinPain skinPain = new SkinPain(attributeName, resId);
                    skinPains.add(skinPain);
                }
            }
        }
        //SkinViewSupport是自定义view实现的接口,用来区分是否需要换肤
        if (!skinPains.isEmpty() || view instanceof TextView || view instanceof SkinViewSupport) {
            SkinView skinView = new SkinView(view, skinPains);
            skinView.applySkin(mTypeface);
            skinViews.add(skinView);
        }
    }

    ...

    }
2.加载皮肤包

加载皮肤包需要我们动态获取网络下载的皮肤包资源,问题是我们如何加载皮肤包中的资源

Android访问资源使用的是Resources这个类,但是程序里面通过getContext获取到的Resources实例实际上是对应程序本来的资源的实例,也就是说这个实例只能加载app里面的资源,想要加载皮肤包里面的就不行了

自己构造一个Resources(这个Resources指向的资源就是我们的皮肤包) 看看Resources的构造方法,可以看到主要是需要一个AssetManager

代码语言:javascript复制
public Resources(AssetManager assets, DisplayMetrics metrics, Configuration config) {
        this(null);
        mResourcesImpl = new ResourcesImpl(assets, metrics, config, new DisplayAdjustments());
    }

构造一个指向皮肤包的AssetManager,但是这个AssetManager是不能直接new出来的,这里就使用反射来实例化了

代码语言:javascript复制
AssetManager assetManager = AssetManager.class.newInstance();

AssetManager有一个addAssetPath方法可以指定资源的位置,可惜这个也只能用反射来调用

代码语言:javascript复制
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
        addAssetPath.invoke(assetManager, filePath);

再来看看Resources的其他两个参数,一个是DisplayMetrics,一个是Configuration,这两的就可以直接使用app原来的Resources里面的就可以。

具体代码如下:

代码语言:javascript复制
    public void loadSkin(String path) {
        if(TextUtils.isEmpty(path)){
            // 记录使用默认皮肤
            SkinPreference.getInstance().setSkin("");
            //清空资源管理器, 皮肤资源属性等
            SkinResources.getInstance().reset();
        } else {
            try {
                //反射创建AssetManager
                AssetManager manager = AssetManager.class.newInstance();
                // 资料路径设置
                Method addAssetPath = manager.getClass().getMethod("addAssetPath", String.class);
                addAssetPath.invoke(manager, path);

                Resources appResources = this.application.getResources();
                Resources skinResources = new Resources(manager,
                        appResources.getDisplayMetrics(), appResources.getConfiguration());

                //记录当前皮肤包
                SkinPreference.getInstance().setSkin(path);
                //获取外部Apk(皮肤薄) 包名
                PackageManager packageManager = this.application.getPackageManager();
                PackageInfo packageArchiveInfo = packageManager.getPackageArchiveInfo(path, PackageManager.GET_ACTIVITIES);
                String packageName = packageArchiveInfo.packageName;

                SkinResources.getInstance().applySkin(skinResources,packageName);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }

        setChanged();
        //通知观者者,进行替换资源
        notifyObservers();
    }
3.替换资源

换肤的核心操作就是替换资源,这里采用观察者模式,被观察者是我们的换肤管理类SkinManager,观察者是我们之前缓存的每个页面的LayoutInflater.Factory2

代码语言:javascript复制
    @Override
    public void update(Observable o, Object arg) {
        //状态栏
        SkinThemeUtils.updataStatusBarColor(activity);
        //字体
        Typeface skinTypeface = SkinThemeUtils.getSkinTypeface(activity);
        skinAttribute.setTypeface(skinTypeface);
        //更换皮肤
        skinAttribute.applySkin();
    }

applySkin()在去遍历每个factory缓存的需要换肤的view,调用他们的换肤方法

代码语言:javascript复制
    public void applySkin() {
        for (SkinView mSkinView : skinViews) {
            mSkinView.applySkin(mTypeface);
        }
    }

applySkin方法如下:

代码语言:javascript复制
        public void applySkin(Typeface typeface) {
            //换字体
            if(view instanceof TextView){
                ((TextView) view).setTypeface(typeface);
            }
            //自定义view换肤
            if(view instanceof SkinViewSupport){
                ((SkinViewSupport)view).applySkin();
            }

            for (SkinPain skinPair : skinPains) {
                Drawable left = null, top = null, right = null, bottom = null;
                switch (skinPair.attributeName) {
                    case "background":
                        Object background = SkinResources.getInstance().getBackground(
                                skinPair.resId);
                        //Color
                        if (background instanceof Integer) {
                            view.setBackgroundColor((Integer) background);
                        } else {
                            ViewCompat.setBackground(view, (Drawable) background);
                        }
                        break;
                    case "src":
                        background = SkinResources.getInstance().getBackground(skinPair
                                .resId);
                        if (background instanceof Integer) {
                            ((ImageView) view).setImageDrawable(new ColorDrawable((Integer)
                                    background));
                        } else {
                            ((ImageView) view).setImageDrawable((Drawable) background);
                        }
                        break;
                    case "textColor":
                        ((TextView) view).setTextColor(SkinResources.getInstance().getColorStateList
                                (skinPair.resId));
                        break;
                    case "drawableLeft":
                        left = SkinResources.getInstance().getDrawable(skinPair.resId);
                        break;
                    case "drawableTop":
                        top = SkinResources.getInstance().getDrawable(skinPair.resId);
                        break;
                    case "drawableRight":
                        right = SkinResources.getInstance().getDrawable(skinPair.resId);
                        break;
                    case "drawableBottom":
                        bottom = SkinResources.getInstance().getDrawable(skinPair.resId);
                        break;
                    case "skinTypeface" :
                        applyTypeface(SkinResources.getInstance().getTypeface(skinPair.resId));
                        break;
                    default:
                        break;
                }
                if (null != left || null != right || null != top || null != bottom) {
                    ((TextView) view).setCompoundDrawablesWithIntrinsicBounds(left, top, right,
                            bottom);
                }
            }
        }

这里能看到换肤的实现方式就是根据原始资源Id来获取皮肤包的资源Id,从而加载资源。因此我们要保证app和皮肤包的资源名称一致

代码语言:javascript复制
    public Drawable getDrawable(int resId) {
        //如果有皮肤  isDefaultSkin false 没有就是true
        if (isDefaultSkin) {
            return mAppResources.getDrawable(resId);
        }
        int skinId = getIdentifier(resId);//查找对应的资源id
        if (skinId == 0) {
            return mAppResources.getDrawable(resId);
        }
        return mSkinResources.getDrawable(skinId);
    }

    //获取皮肤包中对应资源的id
    public int getIdentifier(int resId) {
        if (isDefaultSkin) {
            return resId;
        }
        //在皮肤包中的资源id不一定就是 当前程序的 id
        //获取对应id 在当前的名称 例如colorPrimary
        String resName = mAppResources.getResourceEntryName(resId);//ic_launcher   /colorPrimaryDark
        String resType = mAppResources.getResourceTypeName(resId);//drawable
        int skinId = mSkinResources.getIdentifier(resName, resType, mSkinPkgName);//使用皮肤包的Resource
        return skinId;
    }
4.皮肤包的生成

其实很简单,就是我们重新建立一个项目(这个项目里面的资源名字和需要换肤的项目的资源名字是对应的就可以),记住我们是通过名字去获取资源,不是id

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

面试复习笔记

这份资料我从春招开始,就会将各博客、论坛。网站上等优质的Android开发中高级面试题收集起来,然后全网寻找最优的解答方案。每一道面试题都是百分百的大厂面经真题 最优解答。包知识脉络 诸多细节。

节省大家在网上搜索资料的时间来学习,也可以分享给身边好友一起学习。

接下来是给大家分享的我的面试复习路线,有需要的朋友可以参考一下:

1、看视频进行系统学习

前几年的Crud经历,让我明白自己真的算是菜鸡中的战斗机,也正因为Crud,导致自己技术比较零散,也不够深入不够系统,所以重新进行学习是很有必要的。我差的是系统知识,差的结构框架和思路,所以通过视频来学习,效果更好,也更全面。关于视频学习,个人可以推荐去B站进行学习,B站上有很多学习视频,唯一的缺点就是免费的容易过时。

2、进行系统梳理知识,提升储备

客户端开发的知识点就那么多,面试问来问去还是那么点东西。所以面试没有其他的诀窍,只看你对这些知识点准备的充分程度。so,出去面试时先看看自己复习到了哪个阶段就好。

系统学习方向:

  • 架构师筑基必备技能:深入Java泛型 注解深入浅出 并发编程 数据传输与序列化 Java虚拟机原理 反射与类加载 动态代理 高效IO
  • Android高级UI与FrameWork源码:高级UI晋升 Framework内核解析 Android组件内核 数据持久化
  • 360°全方面性能调优:设计思想与代码质量优化 程序性能优化 开发效率优化
  • 解读开源框架设计思想:热修复设计 插件化框架解读 组件化框架设计 图片加载框架 网络访问框架设计 RXJava响应式编程框架设计 IOC架构设计 Android架构组件Jetpack
  • NDK模块开发:NDK基础知识体系 底层图片处理 音视频开发
  • 微信小程序:小程序介绍 UI开发 API操作 微信对接
  • Hybrid 开发与Flutter:Html5项目实战 Flutter进阶

知识梳理完之后,就需要进行查漏补缺,所以针对这些知识点,我手头上也准备了不少的电子书和笔记,这些笔记将各个知识点进行了完美的总结。

3、读源码,看实战笔记,学习大神思路

“编程语言是程序员的表达的方式,而架构是程序员对世界的认知”。所以,程序员要想快速认知并学习架构,读源码是必不可少的。阅读源码,是解决问题 理解事物,更重要的:看到源码背后的想法;程序员说:读万行源码,行万种实践。

4、面试前夕,刷题冲刺

面试的前一周时间内,就可以开始刷题冲刺了。请记住,刷题的时候,技术的优先,算法的看些基本的,比如排序等即可,而智力题,除非是校招,否则一般不怎么会问。

关于面试刷题,我个人也准备了一套系统的面试题,帮助你举一反三。

以上内容均免费分享给大家,需要完整版的朋友,点这里可以看到全部内容

0 人点赞