PaintedSkin 一款解决Android App 换肤功能的框架

PaintedSkin

    一款解决Android App 换肤框架,极低的侵入性与学习成本。

效果展示

框架效果视频


最新版本

模块 说明 版本
PaintedSkin 换肤核心包 3.1.6
StandardPlugin 减少代码侵入的插件包 3.1.6
AutoPlugin 全自动插件包 3.1.6
ConstraintLayoutCompat ConstraintLayout换肤兼容包 3.1.6
TypefacePlugin 替换字体插件 3.1.6

项目地址 喜欢就给个Star 吧!


框架实现原理

TODO

功能介绍

  1. 支持XML全部View换肤
  2. 支持XML指定View换肤
  3. 支持代码创建View换肤
  4. 支持自定义View、三方库提供的View、自定义属性换肤
  5. 支持绝大部分基础View换肤
  6. 支持差异化换肤(适用于部分View节日换肤)
  7. [支持全局动态替换字体](#TypefacePlugin 使用)
  8. 支持通过拦截器拦截View创建过程
  9. 支持Androidx、support
  10. 支持定制扩展
  11. 不会与其他依赖LayoutInflater.Factory 的库冲突

使用

添加依赖

  1. 在工程的build.gradle文件中添加:

buildscript {
    repositories {
          maven { url "https://jitpack.io" } // 必须添加
    }
    dependencies {
        ...
        classpath 'com.hujiang.aspectjx:gradle-android-plugin-aspectjx:2.0.10' // 如果不使用AutoPlugin可以不添加
    }
    allprojects {
         maven { url "https://jitpack.io" } // 必须添加
    }
}

  1. 如需使用AutoPlugin,在项目appbuild.gradle文件中添加:

apply plugin: 'android-aspectjx' 
android {
    ...
}

  1. 在项目appbuild.gradle文件中添加::

dependencies {
    // 依赖的反射库
    implementation 'com.github.CoderAlee:Reflex:1.2.0'
    // 核心库
    implementation 'com.github.CoderAlee.PaintedSkin:PaintedSkin:TAG'
    implementation 'com.github.CoderAlee.PaintedSkin:StandardPlugin:TAG'
    // StandardPlugin 与 AutoPlugin 只需添加一个
    annotationProcessor 'com.github.CoderAlee.PaintedSkin:AopPlugin:TAG'
    implementation 'com.github.CoderAlee.PaintedSkin:AopPlugin:TAG'
    //如果项目中的ConstraintLayout需要换肤则引入
    implementation 'com.github.CoderAlee.PaintedSkin:ConstraintLayoutCompat:TAG'
    // 需要替换字体库时引入
    implementation 'com.github.CoderAlee.PaintedSkin:TypefacePlugin:TAG'
    ...
}

运行配置

PaintedSkin支持三种换肤模式:

SkinMode.REPLACE_ALL 所有View都参与换肤,添加了skin:enable="false" 标签的View 将不参与换肤;

SkinMode.REPLACE_MARKED 只有添加了skin:enable="true"标签的View才参与换肤;

SkinMode.DO_NOT_REPLACE 任何View都不参与换肤

API:

public final class App extends Application {
    static {
        Config.getInstance().setSkinMode(Config.SkinMode.REPLACE_ALL);
    }
}

PaintedSkin 支持调试模式与严格模式:

调试模式下将输出框架内的一些关键节点Log以及换肤任务执行耗时时长;

严格模式下如果框架内出现错误将直接抛出异常;

API:

public final class App extends Application {
    static {
         Config.getInstance().setEnableDebugMode(false);
         Config.getInstance().setEnableStrictMode(false);
    }
}

插件使用

 `StandardPlugin` 使用:
public final class App extends Application {
    
    @Override
    public void onCreate() {
        super.onCreate();
        WindowManager.getInstance().init(this,new OptionFactory());
    }
}
final class OptionFactory implements IOptionFactory {
    @Override
    public int defaultTheme() {
        return 0;
    }

    @Override
    public IThemeSkinOption requireOption(int theme) {
        switch (theme) {
            case 1:
                return new NightOption();
            default:
                return null;
        }
    }
}

AutoPlugin 不再需要开发人员调用初始化代码,只需要在实现了IOptionFactory 接口的实现类上添加注解@Skin 即可:

@Skin
public final class OptionFactory implements IOptionFactory {
    @Override
    public int defaultTheme() {
        return 0;
    }

    @Override
    public IThemeSkinOption requireOption(int theme) {
        switch (theme) {
            case 1:
                return new NightOption();
            default:
                return null;
        }
    }
}

主题配置

class NightOption implements IThemeSkinOption {

    @Override
    public LinkedHashSet<String> getStandardSkinPackPath() {
        LinkedHashSet<String> pathSet = new LinkedHashSet<>();
        pathSet.add("/sdcard/night.skin");
        return pathSet;
    }
}

换肤

 ThemeSkinService.getInstance().switchThemeSkin(int theme);

皮肤包构建

  1. 新建Android application工程

  2. 皮肤工程包名不能和宿主应用包名相同

  3. 将需要换肤的资源放置于res对应目录下

    例如 Button 文字颜色

    APK 中res/values/colors.xml

    <color name="textColor">#FFFFFFFF</color>
    

    皮肤包中 res/values/colors.xml

    <color name="textColor">#FF000000</color>
    

    例如 Button 背景图片

    APK 中 res/mipmap/bg_button.png

    皮肤包中 res/mipmap/bg_button.png

  4. 在皮肤包工程的build.gradle文件中添加:

      applicationVariants.all { variant ->
            variant.outputs.all { output ->
                outputFileName = "xxx.skin"
            }
        }
    

动态创建View换肤

核心接口WindowManager.getInstance().getWindowProxy(getContext()).addEnabledThemeSkinView(View,SkinElement);

  TextView textView = new TextView(getContext());
        textView.setTextColor(getResources().getColor(R.color.textColor));
        textView.setText("动态创建View参与换肤");
        WindowManager.getInstance().getWindowProxy(getContext()).addEnabledThemeSkinView(textView, new SkinElement("textColor", R.color.textColor));
        layout.addView(textView);

进阶用法

拦截View创建过程

        ThemeSkinService.getInstance().getCreateViewInterceptor().add(new LayoutInflater.Factory2() {
            @Nullable
            @Override
            public View onCreateView(@Nullable View parent, @NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
                return onCreateView(name, context, attrs);
            }

            @Nullable
            @Override
            public View onCreateView(@NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
                if (TextUtils.equals(name,"TextView")){
                    return new Button(context, attrs);
                }
                return null;
            }
        });

通过拦截View的创建过程其实可以实现很多骚操作,比如上面这段代码就可以将全局的TextView替换成Button。这比在XML中一个一个修改要快捷方便的多。其中Google 就是通过这种方式将Button 替换为AppCompatButton。AppCompatDelegate也是同样的技术方案。

自定义View、三方库View换肤

当自定义View或使用的三方库View中有自定义属性需要换肤时:

  1. 实现IThemeSkinExecutorBuilder 接口,用于解析支持换肤属性并创建对应属性的换肤执行器。可以参考框架内自带的DefaultExecutorBuilder:
@RestrictTo(RestrictTo.Scope.LIBRARY)
public final class DefaultExecutorBuilder implements IThemeSkinExecutorBuilder {
    /**
     * 换肤支持的属性 背景
     */
    @RestrictTo(RestrictTo.Scope.LIBRARY)
    public static final String ATTRIBUTE_BACKGROUND = "background";
    /**
     * 换肤支持的属性 前景色
     */
    @RestrictTo(RestrictTo.Scope.LIBRARY)
    public static final String ATTRIBUTE_FOREGROUND = "foreground";
    /**
     * 换肤支持的属性 字体颜色
     */
    @RestrictTo(RestrictTo.Scope.LIBRARY)
    public static final String ATTRIBUTE_TEXT_COLOR = "textColor";
    /**
     * 换肤支持的属性 暗示字体颜色
     */
    @RestrictTo(RestrictTo.Scope.LIBRARY)
    public static final String ATTRIBUTE_TEXT_COLOR_HINT = "textColorHint";
    /**
     * 换肤支持的属性 选中时高亮背景颜色
     */
    @RestrictTo(RestrictTo.Scope.LIBRARY)
    public static final String ATTRIBUTE_TEXT_COLOR_HIGH_LIGHT = "textColorHighlight";
    /**
     * 换肤支持的属性 链接的颜色
     */
    @RestrictTo(RestrictTo.Scope.LIBRARY)
    public static final String ATTRIBUTE_TEXT_COLOR_LINK = "textColorLink";
    /**
     * 换肤支持的属性 进度条背景
     */
    @RestrictTo(RestrictTo.Scope.LIBRARY)
    public static final String ATTRIBUTE_PROGRESS_DRAWABLE = "progressDrawable";
    /**
     * 换肤支持的属性 ListView分割线
     */
    @RestrictTo(RestrictTo.Scope.LIBRARY)
    public static final String ATTRIBUTE_LIST_VIEW_DIVIDER = "divider";
    /**
     * 换肤支持的属性 填充内容
     */
    @RestrictTo(RestrictTo.Scope.LIBRARY)
    public static final String ATTRIBUTE_SRC = "src";
    /**
     * 换肤支持的属性 按钮背景
     */
    @RestrictTo(RestrictTo.Scope.LIBRARY)
    public static final String ATTRIBUTE_BUTTON = "button";
    private static final Map<Integer, String> SUPPORT_ATTR = new HashMap<>();

    static {
        SUPPORT_ATTR.put(R.styleable.BasicSupportAttr_android_background, ATTRIBUTE_BACKGROUND);
        SUPPORT_ATTR.put(R.styleable.BasicSupportAttr_android_foreground, ATTRIBUTE_FOREGROUND);
        SUPPORT_ATTR.put(R.styleable.BasicSupportAttr_android_textColor, ATTRIBUTE_TEXT_COLOR);
        SUPPORT_ATTR.put(R.styleable.BasicSupportAttr_android_textColorHint, ATTRIBUTE_TEXT_COLOR_HINT);
        SUPPORT_ATTR.put(R.styleable.BasicSupportAttr_android_textColorHighlight, ATTRIBUTE_TEXT_COLOR_HIGH_LIGHT);
        SUPPORT_ATTR.put(R.styleable.BasicSupportAttr_android_textColorLink, ATTRIBUTE_TEXT_COLOR_LINK);
        SUPPORT_ATTR.put(R.styleable.BasicSupportAttr_android_progressDrawable, ATTRIBUTE_PROGRESS_DRAWABLE);
        SUPPORT_ATTR.put(R.styleable.BasicSupportAttr_android_divider, ATTRIBUTE_LIST_VIEW_DIVIDER);
        SUPPORT_ATTR.put(R.styleable.BasicSupportAttr_android_src, ATTRIBUTE_SRC);
        SUPPORT_ATTR.put(R.styleable.BasicSupportAttr_android_button, ATTRIBUTE_BUTTON);
    }

    /**
     * 解析支持换肤的属性
     *
     * @param context      {@link Context}
     * @param attributeSet {@link AttributeSet}
     * @return {@link SkinElement}
     */
    @RestrictTo(RestrictTo.Scope.LIBRARY)
    @Override
    public Set<SkinElement> parse(@NonNull Context context, @NonNull AttributeSet attributeSet) {
        TypedArray typedArray = context.obtainStyledAttributes(attributeSet, R.styleable.BasicSupportAttr);
        if (null == typedArray) {
            return null;
        }
        Set<SkinElement> elementSet = new HashSet<>();
        try {
            for (Integer key : SUPPORT_ATTR.keySet()) {
                try {
                    if (typedArray.hasValue(key)) {
                        elementSet.add(new SkinElement(SUPPORT_ATTR.get(key), typedArray.getResourceId(key, -1)));
                    }
                } catch (Throwable ignored) {
                }
            }
        } catch (Throwable ignored) {
        } finally {
            typedArray.recycle();
        }
        return elementSet;
    }

    /**
     * 需要换肤执行器
     *
     * @param view    需要换肤的View
     * @param element 需要执行的元素
     * @return {@link ISkinExecutor}
     */
    @Override
    @RestrictTo(RestrictTo.Scope.LIBRARY)
    public ISkinExecutor requireSkinExecutor(@NonNull View view, @NonNull SkinElement element) {
        return BasicViewSkinExecutorFactory.requireSkinExecutor(view, element);
    }

    /**
     * 是否支持属性
     *
     * @param view     View
     * @param attrName 属性名称
     * @return true: 支持
     */
    @Override
    @RestrictTo(RestrictTo.Scope.LIBRARY)
    public boolean isSupportAttr(@NonNull View view, @NonNull String attrName) {
        return SUPPORT_ATTR.containsValue(attrName);
    }
}
  1. 继承BaseSkinExecutor 提供对应属性的换肤执行器:
 public class ViewSkinExecutor<T extends View> extends BaseSkinExecutor<T> {
      
          public ViewSkinExecutor(@NonNull SkinElement fullElement) {
              super(fullElement);
          }
      
          @Override
          protected void applyColor(@NonNull T view, @NonNull ColorStateList colorStateList, @NonNull String attrName) {
              switch (attrName) {
                  case ATTRIBUTE_BACKGROUND:
                  case ATTRIBUTE_FOREGROUND:
                      if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                          applyDrawable(view, new ColorStateListDrawable(colorStateList), attrName);
                      } else {
                          applyColor(view, colorStateList.getDefaultColor(), attrName);
                      }
                      break;
                  default:
                      break;
              }
          }
      
          @Override
          protected void applyColor(@NonNull T view, int color, @NonNull String attrName) {
              switch (attrName) {
                  case ATTRIBUTE_BACKGROUND:
                      view.setBackgroundColor(color);
                      break;
                  case ATTRIBUTE_FOREGROUND:
                      applyDrawable(view, new ColorDrawable(color), attrName);
                      break;
                  default:
                      break;
              }
          }
      
      
          @Override
          protected void applyDrawable(@NonNull T view, @NonNull Drawable drawable, @NonNull String attrName) {
              switch (attrName) {
                  case ATTRIBUTE_BACKGROUND:
                      view.setBackground(drawable);
                      break;
                  case ATTRIBUTE_FOREGROUND:
                      view.setForeground(drawable);
                      break;
                  default:
                      break;
              }
          }
      }
  1. 将自定义的ThemeSkinExecutorBuilder添加到框架中:
ThemeSkinService.getInstance().addThemeSkinExecutorBuilder(xxx);

ConstraintLayout换肤兼容包使用

public final class App extends Application {
    static {
        ConstraintLayoutCompat.init();
    }
}

TypefacePlugin 使用

public final class App extends Application {
    static {
        TypefacePlugin.init();
    }
    
    @Override
    public void onCreate() {
        super.onCreate();
       TypefacePlugin.getInstance().setEnable(true).switchTypeface(Typeface);
    }
}

License Apache-2.0

Copyright [2018] [MingYu.Liu]

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 151,688评论 1 330
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 64,559评论 1 273
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 101,749评论 0 226
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 42,581评论 0 191
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 50,741评论 3 271
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 39,684评论 1 192
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,122评论 2 292
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 29,847评论 0 182
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 33,441评论 0 228
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 29,939评论 2 232
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,333评论 1 242
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 27,783评论 2 236
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,275评论 3 220
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 25,830评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,444评论 0 180
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 34,553评论 2 249
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 34,618评论 2 249

推荐阅读更多精彩内容