Android 换肤(全局换肤,部分换肤,字体替换,导航栏替换,自定义view换肤,夜间/日间模式)

Posted 夜辉疾风

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Android 换肤(全局换肤,部分换肤,字体替换,导航栏替换,自定义view换肤,夜间/日间模式)相关的知识,希望对你有一定的参考价值。

采集

大致流程

  1. 监听所有activity的生命周期回调

    //SkinActivityLifecycle
    application.registerActivityLifecycleCallbacks(new SkinActivityLifecycle());
    
  2. 创建activity的时候自定义布局工厂

    //SkinLayoutFactory
    @Override
    public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle bundle) 
        //activity在创建的时候拿到布局加载器
        LayoutInflater layoutInflater = LayoutInflater.from(activity);
        //创建一个皮肤工厂
        SkinLayoutFactory skinLayoutFactory = new SkinLayoutFactory();
        //给当前activity的布局加载器添加这个工厂
        LayoutInflaterCompat.setFactory2(layoutInflater, skinLayoutFactory);
    
    
  3. 在布局工厂中寻找出所有view的可替换皮肤的标签并保存

    //SkinAttribute
    public void load(View view, AttributeSet attributeSet)
        //……具体寻找标签和保存的操作
    
    

具体实现

1. Application

//application中初始化皮肤管理类SkinManager
public class App extends Application 
    @Override
    public void onCreate() 
        super.onCreate();
        SkinManager.getInstance().init(this);
    

2. SkinManager

//皮肤管理类,用于注册activity的生命周期监听和加载替换皮肤
public class SkinManager extends Observable 
    private Application application;
	//单例
    private static class OnSkinManager 
        private static SkinManager skinManager = new SkinManager();
    

    public static SkinManager getInstance() 
        return OnSkinManager.skinManager;
    

    /**
     * 初始化
     *
     * @param application 当前app的application对象
     */
    public void init(Application application) 
        this.application = application;
        //初始化一个SharedPreferences,用于存储用户使用的皮肤
        SkinPreference.init(application);
        //初始化皮肤资源类
        SkinResources.init(application);
        //注册activity的生命周期回调监听
        application.registerActivityLifecycleCallbacks(new SkinActivityLifecycle());
    

3. SkinActivityLifecycle

//activity的生命周期监听,在每一个activity创建的时候会去寻找皮肤资源并保存和替换
public class SkinActivityLifecycle implements Application.ActivityLifecycleCallbacks 
    //缓存当前activity使用到的Factory,用于在该activity销毁的时候清除掉使用的Factory
    private Map<Activity, SkinLayoutFactory> cacheFactoryMap = new HashMap<>();

    @Override
    public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle bundle) 
        try 
            //activity在创建的时候拿到布局加载器
            LayoutInflater layoutInflater = LayoutInflater.from(activity);

            //参考LayoutInflater源码中的字段mFactorySet的作用:
            //mFactorySet如果添加过一次会变成true,再次添加LayoutInflater的时候则会抛出异常
            //以下处理的目的是为了修改LayoutInflater源码中的字段mFactorySet的状态,使之不抛出异常
            //得到字段mFactorySet
            Field mFactorysets = LayoutInflater.class.getDeclaredField("mFactorySet");
            //设置字段mFactorySet可以被访问
            mFactorysets.setAccessible(true);
            //设置字段mFactorySet的值为false
            mFactorysets.setBoolean(layoutInflater, false);

            //创建一个皮肤工厂
            SkinLayoutFactory skinLayoutFactory = new SkinLayoutFactory();
            //给当前activity的布局加载器添加这个工厂
            LayoutInflaterCompat.setFactory2(layoutInflater, skinLayoutFactory);
            //添加观察者,观察者也可以使用接口代替
            SkinManager.getInstance().addObserver(skinLayoutFactory);
            //添加缓存,以便于activity在销毁的时候删除观察者,以免造成内存泄漏
            cacheFactoryMap.put(activity, skinLayoutFactory);
         catch (Exception e) 
            e.printStackTrace();
        
    
    
    @Override
    public void onActivityDestroyed(@NonNull Activity activity) 
        //删除观察者
        SkinLayoutFactory skinLayoutFactory = cacheFactoryMap.remove(activity);
        //注销观察者
        SkinManager.getInstance().deleteObserver(skinLayoutFactory);
    

4. SkinLayoutFactory

//布局换肤的工厂类,用于采集需要换肤的view
public class SkinLayoutFactory implements LayoutInflater.Factory2, Observer 

    //系统原生view的路径,属于这些路径的才可以换肤,减少消耗和判断
    private static final String[] mClassPrefixList = 
            "android.widget.",
            "android.view.",
            "android.webkit.",
    ;

    //获取view的class的构造方法的参数,一个view有多个构造方法,每个构造方法的参数不同
    private static final Class[] mConstructorSignature = new Class[]Context.class, AttributeSet.class;

    //缓存已经通过反射得到某个view的构造函数,例如textview、button的构造方法,减少内存开销和加快业务流程
    private static final HashMap<String, Constructor<? extends View>> mConstructorCache = new HashMap<>();

    //view属性处理类
    private SkinAttribute skinAttribute;

    //初始化的时候去创建SkinAttribute类
    public SkinLayoutFactory() 
        this.skinAttribute = new SkinAttribute();
    

    //在创建view的时候去采集view,这里一个layout.xml文件中的所有view标签都会在创建的时候进入该方法
    @Nullable
    @Override
    public View onCreateView(@Nullable View parent, @NonNull String s, @NonNull Context context, @NonNull AttributeSet attributeSet) 
        //如果是系统的view,则可以通过全类名得到view
        View view = createViewFromTag(s, context, attributeSet);
        //如果通过全类名拿不到view,则说明当前view是自定义view
        //如果是自定义view则调用createview方法
        if (view == null) 
            view = createView(s, context, attributeSet);
        
        //将当前view的所有参数遍历,拿到符合换肤的参数以及对应的resid
        //第一步采集view,在这里已经完成
        skinAttribute.load(view, attributeSet);
        return view;
    

    /**
     * 创建原生view
     * @param name         标签名。例如:TextView;Button
     * @param context      上下文
     * @param attributeSet 标签参数
     * @return
     */
    private View createViewFromTag(String name, Context context, AttributeSet attributeSet) 
        //检查当前view是否是自定义的view或者android的新view
        //例如:自定义的,com.xxx.xxx.CustormView
        //系统的,com.androidx.action.AtionBar
        if (name.contains(".")) 
            //如果是自定义的或者是系统view则另做处理
            return null;
         else 
            //这里获取原生view
            View view = null;
            //循环去判断当前view的前缀,例如Layout的前缀是android.widget.
            //这里拼接出view的全类名进行反射
            //如果通过反射拿到了view,说明当前全类名是正确的
            //如果通过反射抛出异常了则说明当前全类名是错误的
            //只有通过反射拿到了正确的构造方法才能通过构造方法new出当前view对象
            for (int i = 0; i < mClassPrefixList.length; i++) 
                //拼接如果是原生标签,则去创建,获取到全类名
                view = createView(mClassPrefixList[i] + name, context, attributeSet);
                if (view != null) //通过全类名拿到了view,直接返回出去
                    break;
                
            
            return view;
        
    

    /**
     * 创建一个view
     *
     * @param name         全类名
     * @param context 上下文
     * @param attributeSet 标签参数
     * @return
     */
    private View createView(String name, Context context, AttributeSet attributeSet) 
        //添加缓存,一个xml中如果有多个重复的view,例如多个textview或者button,则缓存的作用就体现出来了
        //只要是相同的view,则不需要每次都去通过反射拿view
        Constructor<? extends View> constructor = mConstructorCache.get(name);
        //没有缓存的构造方法则创建
        if (constructor == null) 
            try 
                //通过全类名拿到class对象
                Class<? extends View> aClass = context.getClassLoader().loadClass(name).asSubclass(View.class);
                //获取到当前class对象中的构造方法
                constructor = aClass.getConstructor(mConstructorSignature);
                //将构造方法缓存起来
                mConstructorCache.put(name, constructor);
             catch (Exception e) 
                //如果抛出异常,说明这个全类名不正确,则直接返回null
                return null;
            
        
        //构造方法获取到了
        if (null != constructor) 
            try 
                //这个操作相当于new 一个对象,new的时候传入构造方法的参数
                return constructor.newInstance(context, attributeSet);
             catch (Exception e) 
               //如果抛出异常,说明这个构造方法和传递进来的参数不正确
                //一般view的构造方法都有一个是:
                //public xxx(Context context, AttributeSet attrs)
                return null;
            
        
        return null;
    

5. SkinAttribute

//Describe: view的属性处理类,采集view和替换资源
public class SkinAttribute 

    //需要换肤的属性集合,已经找出来了
    private static final List<String> mAttribute = new ArrayList<>();

    //需要换肤的view
    private List<SkinView> skinViews = new ArrayList<>();

    //以下这些事需要换肤的属性,如果自己需要替换那些标签属性,则可以继续添加
    static 
        mAttribute.add("background");
        mAttribute.add("src");
        mAttribute.add("textColor");
        mAttribute.add("drawableLeft");
        mAttribute.add("drawableRight");
        mAttribute.add("drawableTop");
        mAttribute.add("drawableBottom");
    
 
    /**
     * 寻找view的可换肤属性并缓存起来
     *
     * @param view         view
     * @param attributeSet 属性
     */
    public void load(View view, AttributeSet attributeSet) 
        List<SkinPain> skinPains = new ArrayList<>();
        //先筛选一遍,需要修改属性的才往下走
        for (int i = 0; i < attributeSet.getAttributeCount(); i++) 
            //获取属性名字
            String attributeName = attributeSet.getAttributeName(i);
            //如果当前属性名字是需要修改的属性则去处理
            if (mAttribute.contains(attributeName)) 
                //拿到属性值,@2130968664
                String attributeValue = attributeSet.getAttributeValue(i);
                //写死的色值,暂时不修改
                if (attributeValue.startsWith("#")) 
                    continue;
                
                int resId;
                //?开头的是系统参数,如下修改
                if (attributeValue.startsWith("?")) 
                    //拿到去掉?后的值。
                    //强转成int,系统编译后的值为int型,即R文件中的id,例如:?123456
                    //系统的资源id下只有一个标签,类似于resource标签下的style标签,但是style下只有一个item标签
                    //所以只拿第一个attrid;
                    int attrId = Integer.parseInt(attributeValue.substring(1));
                    //获得资源id
                    resId = SkinThemeUtils.getResId(view.getContext(), new int[]attrId)[0];
                 else 
                    //其他正常的标签则直接拿到@color/black中在R文件中的@123456
                    //去掉@后的值则可以直接通过setColor(int resId);传入
                    resId = Integer.parseInt(attributeValue.substring(1));
                
                if (resId != 0) 
                    //保存属性名字和对应的id用于换肤使用
                    SkinPain skinPain = new SkinPain(attributeName, resId);
                    skinPains.add(skinPain);
                
            
        
        //如果当前view检查出来了需要替换的资源id,则保存起来
        //上面的循环已经循环出了当前view中的所有需要换肤的标签和redId
        if (!skinPains.isEmpty()) 
            SkinView skinView = new SkinView(view, skinPains);
            skinViews.add(skinView);
        
    
    //保存:参数->id
    public class SkinPain 
        String attrubuteName;//参数名
        int resId;//资源id

        public SkinPain(String attrubuteName, int resId) 
            this.attrubuteName = attrubuteName;
            this.resId = resId;
        
    
    
    //保存view与之对应的SkinPain对象
    public class SkinView 
        View view;
        List<SkinPain> skinPains;

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

制作

  1. 一个没有java代码的apk包,里面有所有相对应名字的资源文件
  2. 放到服务器或者手机sd卡中用于加载并替换

替换

注意事项

  1. 制作好的皮肤包需要先下载到手机sd卡中,也可在app中内置几套默认皮肤
  2. 换肤需要读写sd卡权限
  3. 注意内存泄漏问题

1. 加载皮肤包资源文件

 //换肤
public void change(View view) 
    //拿到sd卡中的皮肤包
    String path = Environment.getExternalStorageDirectory() + File.separator + "skin_apk_1.apk";
    //加载
    SkinManager.getInstance().loadSkin(path);

2.loadSkin(String filePath)

public class SkinManager extends Observable 
	/**
     * 加载皮肤,并保存当前使用的皮肤
     *
     * @param skinPath 皮肤路径 如果为空则使用默认皮肤
     */
    public void loadSkin(String skinPath) 
        //如果传递进来的皮肤文件路径是null,则表示使用默认的皮肤
        if (TextUtils.isEmpty(skinPath)) 
            //存储默认皮肤
            SkinPreference.getInstance().setSkin("");
            //清空皮肤资源属性
            SkinResources.getInstance().reset();
         else 
            //传递进来的有皮肤包的文件路径则加载
            try 
                //皮肤包文件不存在
                if (!new File(skinPath).exists()) 
                    Toast.makeText(application, "文件不存在", Toast.LENGTH_LONG).show();
                    return;
                
                //反射创建AssetManager
                AssetManager assetManager = AssetManager.class.newInstance();
                //通过反射得到方法:public int addAssetPath(String path)方法
                Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
                //设置当前方法可以被访问
                addAssetPath.setAccessible(true);
                //调用该方法,传入皮肤包文件路径
                addAssetPath.invoke(assetManager, skinPath);
                //得到当前app的Resources
                Resources appResource = application.getResources();
                //根据当前的显示与配置(横竖屏、语言等)创建皮肤包的Resources
                Resources skinResource = new Resources(
                        assetManager,
                        appResource.getDisplayMetrics(),
                        appResource.getConfiguration());
                //保存当前用户设置的皮肤包路径
                SkinPreference.getInstance().setSkin(skinPath);
                //获取外部皮肤包的包名,首先得到PackageManager对象
                PackageManager packageManager = application.getPackageManager();
                //通过getPackageArchiveInfo得到外部皮肤包文件的包信息
                PackageInfo info = packageManager.getPackageArchiveInfo(skinPath, PackageManager.GET_ACTIVITIES);
                if (info == null) 
                    //一般解析失败的原因有:
                    //1,没有sd卡权限
                    //2,皮肤包打包有问题
                    Toast.makeText(application, "解析皮肤包失败", Toast.LENGTH_LONG).show();
                    return;
                
                //得到皮肤包包名
                String packageName = info.packageName;
                //开始设置皮肤
                SkinResources.getInstance().applySkin(skinResource, packageName);
             catch (Exception e) 
                e.printStackTrace();
            
        
        //一下观察者操作可以用接口代替
        //通知所有采集的View更新皮肤
        setChanged();
        //被观察者通知所有观察者
        notifyObservers(null);
    

3. SkinResources

/**
 * 皮肤资源类
 * 用来加载本地默认的资源或者皮肤包中的资源
 */
public class SkinResources 

    private static SkinResources instance;

    //皮肤包的资源
    private Resources mSkinResources;
    //皮肤包包名
    private String mSkinPkgName;
    //是否加载默认的皮肤资源
    private boolean isDefaultSkin = trueiOS换肤方案

Android实现apk插件方式换肤

android 换肤框架搭建及使用 (3 完结篇)

android 换肤框架搭建及使用 (3 完结篇)

Android 换肤- 基于databing的一种思路

Android一键换肤原理简述