市场上很多app支持换肤效果,并且还可以从网上下载皮肤包进行加载换肤,接下来就来聊一聊它的实现原理。

思路:首先我们需要知道哪些控件需要实现换肤,有两种方法

第一种:自己整理,通过findViewById一个个实例化出需要执行换肤的控件,在拿到颜色值,或图片后一个个去替换。

第二种:在布局文件初始化的时候通过属性判断去找出需要换肤的控件。

很明显第一种比较麻烦,而且不易维护。

那么今天就看一下第二种,首先要从setContentView下手,看看它里面到底做了什么事情。

@Override
public void setContentView(@LayoutRes int layoutResID) {
    getDelegate().setContentView(layoutResID);
}

继续看getDelegate()

/**
 * @return The {@link AppCompatDelegate} being used by this Activity.
 */
@NonNull
public AppCompatDelegate getDelegate() {
    if (mDelegate == null) {
        mDelegate = AppCompatDelegate.create(this, this);
    }
    return mDelegate;
}

最后查看到setContentView的具体实现:

@Override
public void setContentView(int resId) {
    ensureSubDecor();
    ViewGroup contentParent = (ViewGroup) mSubDecor.findViewById(android.R.id.content);
    contentParent.removeAllViews();
    LayoutInflater.from(mContext).inflate(resId, contentParent);
    mOriginalWindowCallback.onContentChanged();
}

是不是很熟悉 LayoutInflater.from(mContext).inflate(resId, contentParent);

继续看inflate(resId, contentParent);

里面调用了

// Temp is the root view that was found in the xml
final View temp = createViewFromTag(root, name, inflaterContext, attrs);

点进去发现是调用了Factory的 onCreateView方法

/**
 * This routine is responsible for creating the correct subclass of View
 * given the xml element name. Override it to handle custom view objects. If
 * you override this in your subclass be sure to call through to
 * super.onCreateView(name) for names you do not recognize.
 *
 * @param name The fully qualified class name of the View to be create.
 * @param attrs An AttributeSet of attributes to apply to the View.
 *
 * @return View The View created.
 */
protected View onCreateView(String name, AttributeSet attrs)
        throws ClassNotFoundException {
    return createView(name, "android.view.", attrs);
}

具体实现就在createView里了

/**
 * 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.
 *
 * <p>
 * There are two things that can happen in an error case: either the
 * exception describing the error will be thrown, or a null will be
 * returned. You must deal with both possibilities -- the former will happen
 * the first time createView() is called for a class of a particular name,
 * the latter every time there-after for that class name.
 *
 * @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.
 */
public final View createView(String name, String prefix, AttributeSet attrs)
        throws ClassNotFoundException, InflateException {
    Constructor<? extends View> constructor = sConstructorMap.get(name);
    if (constructor != null && !verifyClassLoader(constructor)) {
        constructor = null;
        sConstructorMap.remove(name);
    }
    Class<? extends View> clazz = null;

    try {
        Trace.traceBegin(Trace.TRACE_TAG_VIEW, name);

        if (constructor == null) {
            // Class not found in the cache, see if it's real, and try to add it
            clazz = mContext.getClassLoader().loadClass(
                    prefix != null ? (prefix + name) : name).asSubclass(View.class);

            if (mFilter != null && clazz != null) {
                boolean allowed = mFilter.onLoadClass(clazz);
                if (!allowed) {
                    failNotAllowed(name, prefix, attrs);
                }
            }
            constructor = clazz.getConstructor(mConstructorSignature);
            constructor.setAccessible(true);
            sConstructorMap.put(name, constructor);
        } else {
            // If we have a filter, apply it to cached constructor
            if (mFilter != null) {
                // Have we seen this name before?
                Boolean allowedState = mFilterMap.get(name);
                if (allowedState == null) {
                    // New class -- remember whether it is allowed
                    clazz = mContext.getClassLoader().loadClass(
                            prefix != null ? (prefix + name) : name).asSubclass(View.class);

                    boolean allowed = clazz != null && mFilter.onLoadClass(clazz);
                    mFilterMap.put(name, allowed);
                    if (!allowed) {
                        failNotAllowed(name, prefix, attrs);
                    }
                } else if (allowedState.equals(Boolean.FALSE)) {
                    failNotAllowed(name, prefix, attrs);
                }
            }
        }

        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);
        if (view instanceof ViewStub) {
            // Use the same context when inflating ViewStub later.
            final ViewStub viewStub = (ViewStub) view;
            viewStub.setLayoutInflater(cloneInContext((Context) args[0]));
        }
        mConstructorArgs[0] = lastContext;
        return view;

    } catch (NoSuchMethodException e) {
        final InflateException ie = new InflateException(attrs.getPositionDescription()
                + ": Error inflating class " + (prefix != null ? (prefix + name) : name), e);
        ie.setStackTrace(EMPTY_STACK_TRACE);
        throw ie;

    } catch (ClassCastException e) {
        // If loaded class is not a View subclass
        final InflateException ie = new InflateException(attrs.getPositionDescription()
                + ": Class is not a View " + (prefix != null ? (prefix + name) : name), e);
        ie.setStackTrace(EMPTY_STACK_TRACE);
        throw ie;
    } catch (ClassNotFoundException e) {
        // If loadClass fails, we should propagate the exception.
        throw e;
    } catch (Exception e) {
        final InflateException ie = new InflateException(
                attrs.getPositionDescription() + ": Error inflating class "
                        + (clazz == null ? "<unknown>" : clazz.getName()), e);
        ie.setStackTrace(EMPTY_STACK_TRACE);
        throw ie;
    } finally {
        Trace.traceEnd(Trace.TRACE_TAG_VIEW);
    }
}

这里可以看出,它是根据控件名称通过反射实例化控件:

Constructor<? extends View> constructor = sConstructorMap.get(name);

clazz = mContext.getClassLoader().loadClass( prefix != null ? (prefix + name) : name).asSubclass(View.class);

final View view = constructor.newInstance(args);

onCreateView是Factory接口的方法,那么我们就可以自定义Factory来实现我们自己的逻辑。

public class SkinFactory implements LayoutInflater.Factory {

@Override
public View onCreateView(String name, Context context, AttributeSet attrs) {
    Log.e("skin", "--> "+name);
    return null;
}

}

并在Activity的onCreate方法中把我们自定义的Factory设置进去。

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
    skinFactory = new SkinFactory();
    LayoutInflater.from(this).setFactory(skinFectory);
    super.onCreate(savedInstanceState);
    setContentView(R.layout.skin_layout);
}

运行后SkinFactory中的打印信息如下:

android app 更换皮肤 安卓换肤_android app 更换皮肤

仔细看了一下发现正是Activity布局文件中所有控件的名称。

根据这些名称我们就可以实例化出相应的view,但是其中有些名称并不全,如Button,没关系,我们给他补全就行了。

SkinFactory 中新建一个方法:

private static final String[] sClassPrefixList = {
        "android.widget.",
        "android.view.",
        "android.webkit."
};
private View createView(String name, Context context, AttributeSet attrs) {
    View view = null;
    //这里用  .  来判断是系统控件还是自定义控件
    if (name.contains(".")) {
        //自定义控件直接去创建
        view = SkinUtils.getView(name, context, attrs);
    } else {
         //系统控件因为我们也不知道它是属于哪个包下的,所以要通过for循环去拼接三个包并创建view
        for (String s : sClassPrefixList) {
            String viewPath = s + name;
            view = SkinUtils.getView(viewPath, context, attrs);
            //如果view不为空则说明包名拼接正确,打断循环
            if (view != null) {
                break;
            }
        }
    }
    return view;
}

获取view的方法:

//根据控件名称通过反射实例化控件
public static View getView(String name, Context context, AttributeSet attributeSet) {
    View view = null;
    try {
        Class<?> tClass = context.getClassLoader().loadClass(name);
        Constructor<?> constructor = tClass.getConstructor(new Class[]{Context.class, AttributeSet.class});
        view = (View) constructor.newInstance(context, attributeSet);
    } catch (ClassNotFoundException e) {
        e.printStackTrace();
    } catch (NoSuchMethodException e) {
        e.printStackTrace();
    } catch (IllegalAccessException e) {
        e.printStackTrace();
    } catch (InstantiationException e) {
        e.printStackTrace();
    } catch (InvocationTargetException e) {
        e.printStackTrace();
    }
    return view;
}

其实就是刚才源码中看到的代码(既然要自定义Factory那么就需要自己去实现view的创建)。

接下来我们就来获取这些控件在xml中定义的各种属性:

private void parseViewAttr(View view, Context context, AttributeSet attrs) {
 
    for (int i = 0; i < attrs.getAttributeCount(); i++) {
        //获取属性名称
        String attrName = attrs.getAttributeName(i);
        String attrValue = attrs.getAttributeValue(i);
        //打印一下这两句获取到的是什么东西
        System.out.println(attrName + "-----attrName----------attrValue---------" + attrValue);
        //打印结果:
        //System.out: orientation  -----attrName----------attrValue---------  1
        //System.out: background  -----attrName----------attrValue---------  @2131361792
        //System.out: textColor -----attrName----------attrValue---------  @2130968615
        //.....
        //从打印结果可以看出 attrName 为属性名 , attrValue 为属性值  
                                                                                                                                                                                   //(如 android:background="@mipmap/ic_launcher" attrName为android:background  attrValue为@mipmap/ic_launcher 。
        //只有我们自己写的值才会有@符号(如  @2131361792)
        //获取一个控件中所有的属性
        if (attrValue.startsWith("@")) {
            int id = Integer.parseInt(attrValue.substring(1));
            //获取资源文件类型如  color  drawable
            String entryType = context.getResources().getResourceTypeName(id);//相当于color等资源文件类型
            String entryName = context.getResources().getResourceEntryName(id);//值
         
        }
    }
   
}

看完上面的代码以及注释后,我们可以知道,我们可以获取到view以及其属性,那么就可以通过设置view属性来实现换肤了。

首先需要保存view及属性:

public class SkinAttr {
    //R文件中对应的静态常量(即 @ 2131361792 后面的数字)
    public int id;
    //属性名(如:background,textColor等)
    public String attrName;
    //属性类型(如:color,drawable等)
    public String attrType;

    public SkinAttr(int id, String attrName, String attrType) {
        this.id = id;
        this.attrName = attrName;
        this.attrType = attrType;
    }
}
public class SkinItem {
    //获取到的view
    public View view;
    //view对应的属性集合
    public List<SkinAttr> skinAttrs;

    public SkinItem(View view, List<SkinAttr> skinAttrs) {
        this.view = view;
        this.skinAttrs = skinAttrs;
    }

}

parseViewAttr()方法修改后如下:

private void parseViewAttr(View view, Context context, AttributeSet attrs) {
    List<SkinAttr> skinAttrs = new ArrayList<>();
    for (int i = 0; i < attrs.getAttributeCount(); i++) {
        String attrName = attrs.getAttributeName(i);
        String attrValue = attrs.getAttributeValue(i);
        System.out.println(attrName + "-----attrName----------attrValue---------" + attrValue);

        //获取一个控件中所有的属性
        if (attrValue.startsWith("@")) {
            int id = Integer.parseInt(attrValue.substring(1));
            String entryType = context.getResources().getResourceTypeName(id);//相当于color等资源文件类型
            String entryName = context.getResources().getResourceEntryName(id);//值
            SkinAttr skinAttr = new SkinAttr(id, attrName, entryType);
            skinAttrs.add(skinAttr);
        }
    }
    SkinItem skinItem = new SkinItem(view, skinAttrs);
    skinItems.add(skinItem);
}

skinItems即获取到的所有view集合。

当我们需要修改属性时只需要操作skinItems就可以了。

完整SkinItem代码:

public class SkinItem {
    public View view;
    public List<SkinAttr> skinAttrs;

    public SkinItem(View view, List<SkinAttr> skinAttrs) {
        this.view = view;
        this.skinAttrs = skinAttrs;
    }

    /**
     * 开始换肤
     */
    public void apply() {
        for (int i = 0; i < skinAttrs.size(); i++) {
            //这里可以根据自己的意愿去添加相应的case,想改背景就判断background,想改字体颜色就判断textColor。
            switch ((skinAttrs.get(i).attrName)) {
                case "background":
                    //背景可以设置颜色和图片
                    if (skinAttrs.get(i).attrType.equals("drawable")||skinAttrs.get(i).attrType.equals("mipmap")) {                                 
                     //根据id,类型,属性名获取drawable
view.setBackgroundDrawable(SkinManager.getInstance().getDrawable(skinAttrs.get(i).id,skinAttrs.get(i).attrType));
                    } else if (skinAttrs.get(i).attrType.equals("color")) {
                        //根据id,类型,属性名获取color
                        view.setBackgroundColor(SkinManager.getInstance().getColor(skinAttrs.get(i).id));
                    }
                    break;
                case "textColor":
                    if (view instanceof TextView) {
                        ((TextView) view).setTextColor(SkinManager.getInstance().getColor(skinAttrs.get(i).id));
                    }
                    break;
            }
        }
    }
}

获取Drawable和color的具体实现:

/**
 * 获取颜色资源
 *
 * @param color
 * @return
 */
public int getColor(int color) {
     //首先获取本app内的资源
    int mycolor = context.getResources().getColor(color);
     //resources为插件apk的resources,如果resources不为空说明加载了插件apk
    if (resources != null) {
        //获取插件apk中的资源
        String entryName = context.getResources().getResourceEntryName(color);
        int resid = resources.getIdentifier(entryName, "color", packageName);
        if (resid > 0) {
            mycolor = resources.getColor(resid);
        }
    }
    return mycolor;
}
/**
 * 获取图片资源
 *
 * @param color
 * @return
 */
public Drawable getDrawable(int color, String type) {
    Drawable mydrawable = context.getResources().getDrawable(color);
  
    if (resources != null) {
        //获取插件apk中的资源
        String entryName = context.getResources().getResourceEntryName(color);
        int resid = resources.getIdentifier(entryName, type, packageName);
        if (resid > 0) {
            mydrawable = resources.getDrawable(resid);
        }
    }
    return mydrawable;
}

然后就剩最后一步:加载插件apk

想要获取apk资源首先需要获取Resource,因为获取资源时都是getResource().getxxxx,   getResource()指定了apk文件的路径

  //assetManager用来指定apk文件路径,后面两个参数为设备的屏幕信息,这里不关心它。


Resource resources = new Resources(assetManager, context.getResources().getDisplayMetrics(), context.getResources().getConfiguration());


  而AssetManager构造方法中标有{@hide},说明它是一个隐藏的方法,外界不能通过new来创建。所以只能通过反射来获取该类实例。

//通过反射获得AssetManager
AssetManager assetManager = AssetManager.class.newInstance();
//addAssetPath为添加apk路径的方法
Method method = AssetManager.class.getDeclaredMethod("addAssetPath", String.class);
//执行方法
//这里path为插件apk路径
method.invoke(assetManager, path);

完整的加载插件apk方法:

public void getOtherApkSrc(String path) {

    try {
        //path为空
        if (path == null) {
            return;
        }
        //通过反射获得AssetManager
        AssetManager assetManager = AssetManager.class.newInstance();
        //获取添加apk路径的方法
        Method method = AssetManager.class.getDeclaredMethod("addAssetPath", String.class);
        //执行方法
        method.invoke(assetManager, path);
        resources = new Resources(assetManager, context.getResources().getDisplayMetrics(), context.getResources().getConfiguration());
        //获取插件apk的包名
        packageName = context.getPackageManager().getPackageArchiveInfo(path, PackageManager.GET_ACTIVITIES | PackageManager.GET_SERVICES).packageName;
    } catch (InstantiationException e) {
        e.printStackTrace();
    } catch (IllegalAccessException e) {
        e.printStackTrace();
    } catch (NoSuchMethodException e) {
        e.printStackTrace();
    } catch (InvocationTargetException e) {
        e.printStackTrace();
    }
}

//这里获得了resource以及其包名。

  测试:在Activity中调用更换皮肤

public void changeColor(View view) {
    skinManager = SkinManager.getInstance();
    skinManager.init(SkinActivity.this);
    String apkPath = SkinUtils.getPath("app-1.apk");
    skinManager.getOtherApkSrc(apkPath);
    skinFectory.apply();
}

调用前:

 

android app 更换皮肤 安卓换肤_Android_02

调用后

android app 更换皮肤 安卓换肤_控件_03

粗略的记录一下,以后用得着了不至于一脸懵逼。。。