Skip to content

4、性能测试

JailedBird edited this page Dec 5, 2023 · 3 revisions

注:测试设备SSD为顶配PCIE4 zhitai 7100,磁盘性能会影响观测结果(PS:长江存储牛逼😘)

项目地址:https://github.com/JailedBird/ModuleExpose

开篇提到,ModuleExpose通过脚本实现自动暴露,并保证编译时module和moudle_expose的代码完全同步;那深究下ModuleExpose includeWithExpose等函数的执行时机是什么?又为什么能保证代码是完全同步的呢?

这个和gradle生命周期有关,我不是很懂,但已知有:

  • 项目sync时候,会执行setting.gradle.kts文件,同步工程模块
  • 项目运行时候,会执行setting.gradle.kts文件,同步工程模块

setting.gradle.kts中使用自定义的includeWithExpose函数,实现原有include module功能,并生成和include module_expose,这个操作在每次run运行都是会执行的,因此expose脚本的处理方式和性能,会对项目编译产生极大的影响!

1、分析下脚本includeWithExpose处理细节和性能问题

fun includeWithExpose(module: String, isJava: Boolean, expose: String, condition: (String) -> Boolean) {
    include(module)
    measure("Expose ${module}", true) {
        val moduleProject = project(module)
        val src = moduleProject.projectDir.absolutePath
        val des = "${src}_${MODULE_EXPOSE_TAG}"
        // generate build.gradle.kts
        generateBuildGradle(
            src, BUILD_TEMPLATE_PATH_CUSTOM, des,
            "build.gradle.kts", moduleProject.name, isJava
        )
        doSync(src, expose, condition)
        // Add module_expose to Project!
        include("${module}_${MODULE_EXPOSE_TAG}")
    }
}
  • include module,这个本身就需要做,不计入额外损耗;
  • generateBuildGradle,创建module_expose的build.gradle.kts,涉及单个文件的拷贝;
  • doSync,源码文件的同步,处理稍微复杂,后续单独说;
  • include module_expose, 这个操作是将module_expose include到工程,开销和include module相当,实测无影响;

总体看, 除第三步文件同步doSync,其他的性能损耗都无关紧要;

2、分析下doSync文件同步的细节:

fun doSync(src0: String, expose: String, condition: (String) -> Boolean) {
    val start = System.currentTimeMillis()
    val src = "${src0}${File.separator}src${File.separator}main"
    val des = "${src0}_${MODULE_EXPOSE_TAG}${File.separator}src${File.separator}main"
    // Do not delete
    val root = File(src)
    val pathList = mutableListOf<String>()
    if (root.exists() && root.isDirectory) {
        measure("findDirectoryByNio") {
        	// 1): 使用NIO 搜索名称为expose目录
            findDirectoryByNIO(src, expose, pathList)
        }
    }
    pathList.forEach { copyFrom ->
        val suffix = copyFrom.removePrefix(src)
        val copyTo = des + suffix
        measure("syncDirectory $copyFrom") {
            // 2): 实现文件同步 
            //	a) 先遍历module_expose删除不存在于module中的文件 
            //	b) 将module中的文件,通过NIO StandardCopyOption.REPLACE_EXISTING模式直接拷贝
            syncDirectory(copyFrom, copyTo, condition)
        }
        // 3):删除空目录
        measure("Delete empty dir") {
            // remove empty dirs
            deleteEmptyDir(copyTo)
        }
    }
    debug("Module $src all spend ${(System.currentTimeMillis() - start)} ms")
}

1)、 目录搜索

基于NIO实现目录遍历,搜索所有名称为expose的目录;使用NIO的直接原因在于NIO的性能远高于Java IO;开发时使用Java IO基于递归搜索,耗时12+ms,而NIO耗时1~2ms;从时间复杂度来看,此算法时间复杂度为N,N为目录数量,实际项目N一般很小,耗时可以忽略不计;

2)、 文件同步

基于1的目录搜索结果,在expose目录下定点文件同步,可以减少很多开销和遍历;

a)删除module_expose expose目录下,不存在于module expose中的文件, 文件删除比较耗时,貌似单个文件0.5ms的样子

b)以替换拷贝形式(REPLACE_EXISTING),复制module expose目录下的文件,到module_expose expose目录下,文件拷贝比较耗时,貌似也差不多单个文件0.5~1ms;每次同步都需要拷贝,涉及IO读写,相当耗时,很不完美,后续有优化方案

3)、删除空目录

主要是精简目录结构,耗时不多;不在意空目录对视觉干扰的甚至可以去掉;另外这是基于Java IO的操作,写代码的是这块暂时没优化到;

2023/11/05更新: 后期代码添加了module_expose_clean任务,在执行clean时会完全删除expose模块;这种方案更加好,clean后不会造成过多的空目录,因此移除每次运行都要执行的 空目录删除操作

task("clean").dependsOn("module_expose_clean")
// This task is used for delete xx_expose module
tasks.register("module_expose_clean"){
    doLast {
        println("execute clean expose")
        subprojects.forEach{ project->
            // Please exclude these project that you don't want to delete
            if(project.name.endsWith("_expose")){
                println("ModuleExpose: delete ${project.path}")
                project.projectDir.deleteRecursively()
            }
        }
    }
}

3、 优化理论及方案

1、 绝大部分情况,我们是不会修改expose中任何代码的,因此可以认为 95%+ 情况下的文件同步,都只是 2-b)中描述的【文件同步-替换拷贝】情况;因此直接读取拷贝源文件,拷贝目的文件,对比他们是否存在差异,性能是否会更好?前者属于一次读一次写,后者是两次读外加内存equal操作(equal相对可忽略)

经过尝试,如下的差异对比方案有更好的性能,单个小文件处理耗时不超过1ms,符合读文件效率高于写文件的事实;

// 文件内容对比
fun areFilesContentEqual(path1: Path, path2: Path,tag:String=""): Boolean {
    try {
        val size1 = Files.size(path1)
        val size2 = Files.size(path2)
        if (size1 != size2) {
            return false // Different sizes, files can't be equal
        }
        // 小细节 4MB大文件直接判定不相同,通过文件系统直接拷贝同步 项目中是不可能出现这种情况的!
        if(size1 > 4_000_000){ // 4MB return false 
            return false
        }
        val start = System.currentTimeMillis()
        val content1 = Files.readAllBytes(path1) // Huge file will cause performance problem
        val content2 = Files.readAllBytes(path2)
        val isSame = content1.contentEquals(content2)
        debug("$tag Read ${path1.fileName}*2 & FilesContentEqual spend ${System.currentTimeMillis()-start} ms, isSame $isSame")
        return isSame
    } catch (e: Exception) {
        e.printStackTrace()
    }
    return false
}

当文件存在差异,才进行替换拷贝;


if(areFilesContentEqual(file,destinationFile, "[directorySync]")){
    // Do nothing
}else{
    measure("[directorySync] ${file.fileName} sync with copy REPLACE_EXISTING",true) {
        Files.copy(
            file,
            destinationFile,
            StandardCopyOption.REPLACE_EXISTING
        )
    }

}

2、 改进点,看下老方案吧, a) 针对范围过大,未细化目录;b) 针对api后缀的目录,这个格式不是很灵活;c)每次编译,都会重新 删除、拷贝;这些操作都是格外耗时;

https://github.com/tyhjh/module_api#%E5%85%B7%E4%BD%93%E5%AE%9E%E7%8E%B0
def includeWithApi(String moduleName) {
    //先正常加载这个模块
    include(moduleName)
    // 每次编译删除之前的文件
    deleteDir(targetDir)
    //复制.api文件到新的路径
    copy() {
        from originDir
        into targetDir
        exclude '**/build/'
        exclude '**/res/'
        include '**/*.api'
    }
}

对比看下我的输出结果,clean后的第二次编译下耗时,只能说相对完美 哈哈😘(测试tag:release-1.0.0-beta)

ModuleExpose :core:common spend 2ms
ModuleExpose :feature:settings spend 2ms
ModuleExpose :feature:search spend 2ms
ModuleExpose :feature:about spend 2ms

极端情况下测试:在benchmark模块expose目录下添加100个内容为expose.gradle.kts的文本文件,单文件480行、16.2 KB;

分别测试clean后编译、源码不变情况下的二次编译、修改单个文件的二次编译,耗时如下:

ModuleExpose :feature:settings spend 80ms
ModuleExpose :feature:settings spend 32ms
ModuleExpose :feature:settings spend 34ms 
// 可以看出源码修改的耗时 介于首次编译 ~ 源码不变情况下的二次编译之间 且耗时和修改内容成正比!

综上:性能还是非常优秀的,请大家放心食用😘

3、另外,如果项目中模块暴露的文件已经多到影响编译,那么建议直接将暴露的模块,单独抽出来为真正的模块:

  • module_expose本就来自module,甚至包名都没变,直接将其作为独立模块是完全没问题的,但是要注意将其添加到git中去、当然最好改个名字吧
  • 删除module中expose的内容,然后直接implement module_expose,其他compileOnly module_expose 直接搜索替换为implement module_expose即可;
  • 使用include而非includeWithExpose导入module,避免自动生成module_expose;

Ok,几乎零成本拆除,这也是为什么相比原创项目要将暴露内容集中收敛到expose目录——便于集中管理和后期迁移

综上,绝绝绝大部分情况下,即已生成module_expose并且expose目录内容无任何变化,ModuleExpose额外性能开销约为:2N个文件读耗时+这2N个文件内容的ByteArray对象的contentEquals耗时!其中N为module中expose目录下的文件数量

除MoudleExpose本身的开销,还需要考虑的一个问题是:源码不变情况下每次运行,module同步到module_expose,是否会破坏Transform、注解处理器、模块编译等增量编译机制,进而导致其他的编译耗时问题呢?

  • 在之前不做文件内容校验,直接替换拷贝的情况下,虽然拷贝前后文件内容完全一致,但是从文件管理器可以看出其文件创建时间是变化了的;因为对gradle的编译机制不熟悉,所以我不确定他是否触发了module_expose的重新编译、以及其他可能的副作用;【按理来说可以不重新编译,但是文件时间戳确实又变了🤣】
  • 启用文件内容校验的情况下,module_expose中的文件也不会被重新生成,从任务管理器看文件本身、build文件夹等的创建时间也未变化,因此姑且可以断定 最终的ModuleExpose不会破坏相关的增量机制,造成其他性能问题; (有了解这个的大佬也欢迎指正!)

编译日志如下,看起来确实是不会造成重新编译等问题;

> Task :feature:search:kspProDebugKotlin UP-TO-DATE
> Task :feature:search:compileProDebugKotlin UP-TO-DATE
> Task :feature:search:javaPreCompileProDebug UP-TO-DATE
> Task :feature:search:compileProDebugJavaWithJavac UP-TO-DATE
> Task :feature:search_expose:preBuild UP-TO-DATE
> Task :feature:search_expose:preProDebugBuild UP-TO-DATE
> Task :feature:search_expose:generateProDebugBuildConfig UP-TO-DATE
> Task :feature:search_expose:generateProDebugResValues UP-TO-DATE
> Task :feature:search_expose:generateProDebugResources UP-TO-DATE

另外:虽然但是,还是尽量减少需要expose的内容吧,非必要不暴露!如果项目根本不需要暴露,请不要使用includeWithExpose,直接include!

自定义配置

本节涉及到很多脚本细节,就顺带说下怎么快速自定义配置吧; expose.gradle.kts 中定义了很多自定义配置,比如需要暴露的目录名称、暴露模块名称、日志开关等;

private val MODULE_EXPOSE_TAG = "expose"
private val DEFAULT_EXPOSE_DIR_NAME = "expose"
private val SCRIPT_DIR = "$rootDir/gradle/expose/"
private val BUILD_TEMPLATE_PATH_JAVA = "${SCRIPT_DIR}build_gradle_template_java"
private val BUILD_TEMPLATE_PATH_ANDROID = "${SCRIPT_DIR}build_gradle_template_android"
private val BUILD_TEMPLATE_PATH_CUSTOM = "build_gradle_template_expose"
private val ENABLE_FILE_CONDITION = false
private val MODULE_NAMESPACE_TEMPLATE = "cn.jailedbird.module.%s_expose"
private val DEBUG_ENABLE = false

参考资料

1、思路原创:微信Android模块化架构重构实践

2、项目原创:github/tyhjh/module_api

3、脚本迁移:将 build 配置从 Groovy 迁移到 KTS

4、参考文章:Android模块化设计方案之接口API化

5、Nowinandroid:https://github.com/android/nowinandroid

6、Dagger项目:https://github.com/google/dagger

7、Hilt官方教程:https://developer.android.com/training/dependency-injection/hilt-android

基于模块暴露和Hilt的安卓模块通信方案

Clone this wiki locally