[SML|Gradle|Android] 通过gradle plugin 自动生成资源文件(drawable,color,dimen。。。)

[SML|Gradle|Android] 通过gradle plugin 自动生成资源文件(drawable,color,dimen。。。)

本人所有文章禁止任何形式的转载,谢谢

前言

在一个app 中,UI 通常会给出不同的设计,比如Ok 按钮和Cancel 的样式是不太一样的,每出现一个新的样式,我们就需要创建一个drawable 文件。这都还好,关键是这个drawable 文件有相当多的重复性内容,也就是说完全没有重用。像style 文件还好,不同的style 之间还可以继承。

所以如果能够给出一个自动生成这些文件的工具会更好。

从标题就能够在以后我们要怎么做的了。但是我还是说一下现阶段可以选择的工具

现有的解决方案

  1. 通过注解生成android.graphics.drawable.Drawable(或者完全通过手动调用代码)。然后在代码中使用。
  2. 通过继承现有的View。具体使用时还有点区别,一是通过Provider 自动替换,就像 我们明明用的是android.widget.View, 但是实际展示出来的却是androidx.appcompat.widget。二是在layout 布局中直接使用这些特殊的View。后一个的好处是可以预览到“drawable”的效果。
  3. 通过data binding,在xml 中使用代码,插入android.graphics.drawable.Drawable。(我现阶段使用的)
  4. 使用Material,和方案2 类似,但是有些效果可能没有。
  5. 使用jetpack compose(可能是你最应该选择的)

现有的解决方案有一个问题就是无法预览到效果。虽然预览效果不是什么必须的,但我还是想要搞出来一个拥有预览能力的解决方案。

思考

很显然,自动生成代码吗,注解,gradle plugin,外部工具。

注解的方案还没尝试过,应该也是可以的,但是这类代码不容易写成控制、循环的代码,而且注解的参数有也严格的要求,比如不能使用可变的参数必须得是常量(在kotlin 中更为烦人,只能使用KClass,而不能使用Class)。

我还是选择的是gradle plugin。不过gradle plugin 和外部工具其实非常类似,只是说外部工具有点“脱离生态的感觉“,而且需要”一点点配置“,比如使用者可以把这个工具放到任意的什么目录。

关于通过gradle plugin 生成代码,有很多博客,可以对照着看。

https://juejin.cn/post/6887581345384497165

https://medium.com/@magicbluepenguin/how-to-create-your-first-custom-gradle-plugin-efc1333d4419

接下来就要展示如果完成这个工具。

哎呦,还没有给这个插件起个名字呢!其实名字就在标题里SML,其实就是仿照Sassxml 改的。如果你愿意可以叫他“斯麦鲁,斯麦鲁”😊

开始

项目使用kotlin 以及kotlin dsl 编写,所以需要有一定的前置知识。

  1. 创建一个模块。也可以不进行创建,把代码放到buildSrc中,但是我都已经给它起了个名字了🥹

    模块就是一个普通的kotlin library 即可。

    plugins {
        id 'org.jetbrains.kotlin.jvm'
        id 'java-gradle-plugin'
        id 'maven-publish'
    }
    

    因为代码不是在buildSrc 中,所以要应该这个插件,需要通过maven publish

    gradlePlugin {
        plugins {
            // 声明插件信息,这里的 hello 名字随意
            hello {
                version('0.0.1')
                // 插件ID
                id = 'com.storyteller_f.sml'
                // 插件的实现类
                implementationClass = 'com.storyteller_f.sml.Sml'
            }
        }
    }
    
    publishing {
        repositories {
            maven {
                // $rootDir 表示你项目的根目录
                url = "$rootDir/repo"
            }
        }
    }
    
  2. 定义一个任务

    internal open class ColorTask : DefaultTask() {
        @get:OutputFile
        lateinit var outputFile: File
    
        @get:Input
        lateinit var colorsMap: MutableMap<String, String>
        @TaskAction
        fun makeResources() {
            colorsMap.entries.joinToString { (colorName, color) ->
                "\n    <color name=\"$colorName\">$color</color>"
            }.also { xml ->
                outputFile.writeXlmWithTags(xml)
            }
        }
    }
    

    outputFile.writeXlmWithTags(xml) 是一个扩展函数,就是把拼接好的内容存储到文件中。

    关于注解OutputFileInput 用来给gradle 进行增量更新判断使用。如果输入输出都没有发生变化,这个任务都会跳过。加上很有必要。

    TaskAction 也是必须的,很显然这个函数不是继承自DefaultTask 的,而且这个抽象类中也没有什么函数要继承。只有加了这个注解,gradle 才会运行这个函数。

    代码并不是必须要这么写,只要逻辑没问题应该就好,更多内容可以查看gradle doc的 working_with_files_in_custom_tasks_and_plugins

  3. 定义一个Plugin

    
    class Sml : Plugin<Project> {
        override fun apply(project: Project) {
            val rootPath = "${project.buildDir}/generated"
            project.android().variants().all { variant ->
                val subPath = variant.dirName
                val colorsOutputDirectory =
                    File(File(rootPath, "sml_res_colors"), subPath).apply { mkdirs() }
                project.tasks.register(taskName(variant, "Colors"), ColorTask::class.java) {
                    it.group = "sml"
                    it.outputFile = File(colorsOutputDirectory, "values/generated_colors.xml")
                    it.colorsMap = mutableMapOf()
                    variant.registerGeneratedResFolders(project.files(colorsOutputDirectory).builtBy(it))
                }
    
            }
    
        }
    
        private fun taskName(variant: BaseVariant, type: String) = "generate$type${variant.name.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.ROOT) else it.toString() }}"
    }
    

    关于其中的android 的作用是判断当前是不是一个安卓项目,然后获取variants,一个没有进行过特殊处理的项目variant 只有两个debugrelease,显然我们要为这两个都生成任务。

    variant.registerGeneratedResFolders(project.files(colorsOutputDirectory).builtBy(it)) 也是必须的,要不然android studio不知道我们生成的文件在哪里。

    生成的文件中仅包含color,其他的资源照猫画虎就可以全部做出来。

  4. 接受参数

    上面的例子中,参数是通过it.colorsMap = mutableMapOf() 指定的,因为我们的插件需要提供给不同的模块使用,现阶段不太灵活。

    interface SmlExtension {
        val color: MapProperty<String, String>
        val dimen: MapProperty<String, String>
        val drawables: NamedDomainObjectContainer<DrawableDomain>
    }
    

    仅仅是一个接口,因为我们根本不必实现它,gradle 会帮我们做的,只不过要求是参数需要是制定类型Managed properties。在上面提供的gradle doc 中也包含这部分。

    class Sml : Plugin<Project> {
        override fun apply(project: Project) {
            val extension = project.extensions.create("sml", SmlExtension::class.java)
            val rootPath = "${project.buildDir}/generated"
            // ...
        }
    }
    

    然后我们就可以通过extension 获取到参数了。其实也不是,现在获取的数据是空的,因为我们还没有传递参数。

    sml {
        color.set(mutableMapOf("test" to "#ff0000"))
    }
    

    这里接受的是个map 对象,不管数据源是啥,只要最终转化成一个map 即可。

    这里的sml 就是我们创建extension 是传递的那个sml 参数,这个函数是gradle 据此信息自动生成的。

    /**
    * Retrieves the [sml][com.storyteller_f.sml.SmlExtension] extension.
    */
    val org.gradle.api.Project.`sml`: com.storyteller_f.sml.SmlExtension get() =
        (this as org.gradle.api.plugins.ExtensionAware).extensions.getByName("sml") as com.storyteller_f.sml.SmlExtension
    
    /**
    * Configures the [sml][com.storyteller_f.sml.SmlExtension] extension.
    */
    fun org.gradle.api.Project.`sml`(configure: Action<com.storyteller_f.sml.SmlExtension>): Unit =
        (this as org.gradle.api.plugins.ExtensionAware).extensions.configure("sml", configure)
    
    
  5. 支持更多类型

    上面演示的只有color 和dimen,关于drawable 还没有说。drawable 更为复杂,不能像color 那样用一个MapProperty<String, String> 就给打发了。在这里我们使用NamedDomainObjectContainer,这个对象是一个Collection,可以放入很多数据。

    interface DrawableDomain {
        // Type must have a read-only 'name' property
        val name: String?
    
        val drawable: Property<String>
    }
    

    不过有一个要求是,范型内要求包含一个不可变的字段name。其实还有一个,其他的字段要求是可序列化的🥹。就在写博客前,用的还是自定义的对象,但是太过麻烦,几乎所有相关的类都需要可序列化,所以最好办法的还是接受一个String。而且这样还有一个好处,等会说。

  6. kotlin dsl

    我希望配置参数时的代码更清晰,所以最好能是这种写法。

    sml {
        color.set(mutableMapOf("test" to "#ff0000"))
        dimen.set(mutableMapOf("test1" to "12"))
        drawables {
            register("hello") {
                Rectangle {
                    solid("#00ff00")
                    corners("12dp")
                }
            }
            register("test") {
                Oval {
                    solid("#ff0000")
                }
            }
            register("test1") {
                Ring("10dp", "1dp") {
                    ring("#ffff00", "10dp")
                }
            }
            register("test2") {
                Line {
                    line("#ff0000", "10dp")
                }
            }
        }
    }
    

    所以需要类似这样的扩展函数(真的是体力活)。

    fun DrawableDomain.Oval(block: OvalShapeDrawable.() -> Unit) {
        drawable.set(OvalShapeDrawable().apply {
            start()
        }.apply(block).output())
    }
    
    fun DrawableDomain.Ring(innerRadius: String, thickness: String, block: RingShapeDrawable.() -> Unit) {
        drawable.set(RingShapeDrawable(innerRadius, thickness, false).apply { start() }.apply(block).output())
    }
    
    fun DrawableDomain.Ring(innerRadiusRatio: Float, thicknessRatio: Float, block: RingShapeDrawable.() -> Unit) {
        drawable.set(RingShapeDrawable(innerRadiusRatio.toString(), thicknessRatio.toString(), true).apply { start() }.apply(block).output())
    }
    
    fun DrawableDomain.Line(block: LineShapeDrawable.() -> Unit) {
        drawable.set(LineShapeDrawable().apply { start() }.apply(block).output())
    }
    

    具体相关的类,可以去我的github 看https://github.com/storytellerF/common-ui-list-structure

    上面这种写法你也许不喜欢,不过没关系,因为drawable 最终接受的是一个字符串,所以你也可以写成这样(这就是我上面说的好处)。

    register("test2") {
        drawable.set("hello world")
        //Line {
        //    line("#ff0000", "10dp")
        //}
    }
    
  7. 生成

    现在我们开始生成这些玩具吧,代码写好后,我们需要进行gradle sync,同步之后,在相应模块的任务列表下就能找到我们的任务了。

    TASK.png

    执行任务试试吧。

  8. 成果

    demo.png

    在resource manager 中预览正常😊

    layout.png

    layout 中预览正常😊

后言

当前也是有个小问题的,在生成的资源文件中android studio 不提供预览功能。

shape.png

可能Google 的开发人员认为这个是自动生成的,所以你应该对于生成出来的内容拥有绝对的认知吧。

最优先的的选择应该还是jetpack compose,但是如果因为某些原因而不能,那么“SML” 应该是最好的选择了吧😊

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

推荐阅读更多精彩内容