啊(á)?用BuildSrc管理Android依赖版本已经过时了?Catalogs才是版本答案?我不信!

Android组件化中使用Catalogs管理版本与对应封装

前言

说起 Android 的版本管理方案其实有很多种实现的,但是目前主流是三种方向,config.gradle 和 buildSrc 和 version Catalogs 这三种方案。

从网上的呼声来看目前大家比较喜欢 Catalogs 这种方案,因为如果现在出文章或出 Demo 还是用老的版本方式,会被读者善意的提醒过时了 😂

我之前的组件化方案【传送门】中我是使用 buildSrc + Kotin DSL 的方案来做的版本管理,然后有读者推荐使用 Catalogs 的方案来管理。

那本篇文章我们就带着几个问题往下看:

1、Catalogs 经过这么长时间的改善真的好用吗?代码提示完善吗?版本升级提示可以吗?

2、对比 buildSrc 这种方案会更好吗?他们之间的优缺点是什么?

3、使用 Catalogs 取代 config.gradle 有什么优势?中大型项目使用组件化开发的时候,Catalogs 如何进行集中管理和封装?

话不多说,直接开始!

一、使用BuildSrc的方式有什么弊端

关于 BuildSrc + Kotlin DSL 如何进行版本管理,我在之前的文章中已经详细的介绍了【传送门】

我介绍了这种方案的一些优势

  1. 可以很方便的跳转查看依赖组件,哪些地方有被依赖到一点就能飞。

  2. 可以使用Kotlin语法,并且可以对依赖组进行编队,可以按需进行依赖组的依赖。

  3. 其次还可以通过 Gradle Task 的方式进行封装,子组件只需要依赖这个 GradleTask 即可实现默认的配置。

这里介绍一些缺点

  1. buildSrc 被 Gradle 视为一个独立的项目,它在主项目构建之前自动编译。这意味着,当你对 buildSrc 中的代码做出更改时,Gradle需要重新编译它。如果 buildSrc 项目变得很大,编译时间可能会对总体构建时间产生显著影响。

  2. 语言成面,kotlin 编写脚本在编译时没有 groovy 的速度快,性能上差点,但是也是在编译时,并不影响运行时速度。

  3. 由于 buildSrc 是独立的项目,所以和主工程的 Gradle 可能有版本冲突,需要准备双份的版本强制指定。

在之前的文章我就说过我搞版本冲突搞了2晚上,就是因为这个问题导致 Hilt 无法使用,最后是强制指定双份版本才解决的冲突的问题,如果是其他的依赖不知道会不会也需要这么做。

对于 Kotlin DSL 和 Groovy DSL 可以看看我的之前的项目,我分别做了不同的分支,一模一样的依赖,分别做出三次清除缓存之后的编译测效果如下。

使用 Groovy 的传统方式:

使用 BuildSrc + Kotlin DSL 的方式:

取决于温度,电脑,CPU,其他环境因素,我不敢说原始的 Groovy 的方式一定比 BuildSrc + Kotlin DSL 的方案快,我只能说在我这边的测试结果是 Groovy 的方式相对于 BuildSrc 的方式稍微要快一些,如果你想要更精确的数据可以自行运行尝试。

当然他们对最终APK大小是没有影响的,最终的打包产物,包括最终性能都没有影响,只是在编译期间有区别。

二、使用 Catalogs 的集成

我使用的 Gradle 版本是 8.1.3 不算太高,也不算很低,使用 Catalogs 的方式不需要额外的声明。

我们直接创建文件就能使用

我们常用的四个标签,[versions] [libraries] [bundles] [plugins]

versions

作用: 定义项目中使用的各个依赖的版本号。

用途: 通过为每个版本分配一个标识符,您可以在整个版本目录中重用这些版本号。这样做的好处是,当需要升级依赖版本时,您只需在一个地方更新版本号,改动就会被应用到所有使用该版本的依赖上。

libraries

作用: 定义项目依赖的库。

用途: 在这个部分,您可以具体列出项目需要的库及其坐标(group,name,[version])。您还可以引用 [versions] 部分定义的版本号,从而避免在每个库定义中重复版本号。

bundles

作用: 定义一组相关的依赖,可以一起引入项目中。

用途: 通过创建包(bundle),您可以将相关的依赖分组在一起,然后在项目的不同模块中一次性引入这些依赖。这对于管理那些经常一起使用的库非常有用,如测试库集合、日志库集合等。

plugins

作用: 定义Gradle插件的依赖。

用途: 在这个部分,您可以列出项目中使用的Gradle插件及其版本。同样,您可以利用 [versions] 中定义的版本号来统一管理插件版本。

ini 复制代码
[versions]
compileSdk = "34"
minSdk = "21"
targetSdk = "33"
versionCode = "100"
versionName = "1.0.0"
applicationId = "com.newki.template"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

androidGradlePlugin = "8.1.3"
kotlinVersion = "1.8.22"
coroutinesVersion = "1.7.1"

appcompat = "1.6.1"
supportV4 = "1.0.0"
coreKtx = "1.9.0"
activityKtx = "1.8.0"
fragment = "1.5.4"
fragmentKtx = "1.5.4"
constraintLayout = "2.1.4"
cardView = "1.0.0"
material = "1.11.0"
recyclerView = "1.2.1"
multidex = "2.0.1"
viewpager = "1.0.0"
viewpager2 = "1.1.0-beta01"

lifecycleVersion = "2.7.0"
hiltVersion = "2.45"
navigationVersion = "2.5.3"
dataStoreVersion = "1.1.0-beta01"
workVersion = "2.8.1"

junit = "4.13.2"
androidJunit = "1.1.5"
espresso = "3.5.1"

#第三方
aRouterVersion = "1.0.3"
retrofitVersion = "2.9.0"
glideVersion = "4.11.0"
permissionVersion = "18.6"
gsonFactoryVersion = "9.5"
gsonVersion = "2.10.1"


[libraries]
#基础与UI
appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" }
supportV4 = { module = "androidx.legacy:legacy-support-v4", version.ref = "supportV4" }
coreKtx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
activityKtx = { module = "androidx.activity:activity-ktx", version.ref = "activityKtx" }
fragment = { module = "androidx.fragment:fragment", version.ref = "fragment" }
fragmentKtx = { module = "androidx.fragment:fragment-ktx", version.ref = "fragmentKtx" }
multidex = { module = "androidx.multidex:multidex", version.ref = "multidex" }

#Widget
constraintLayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintLayout" }
recyclerView = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerView" }
cardView = { module = "androidx.cardview:cardview", version.ref = "cardView" }
material = { module = "com.google.android.material:material", version.ref = "material" }

#lifecycle
lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime", version.ref = "lifecycleVersion" }
lifecycle-runtimektx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycleVersion" }
lifecycle-livedata = { module = "androidx.lifecycle:lifecycle-livedata", version.ref = "lifecycleVersion" }
lifecycle-livedataKtx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycleVersion" }
lifecycle-viewModel = { module = "androidx.lifecycle:lifecycle-viewmodel", version.ref = "lifecycleVersion" }
lifecycle-viewModelKtx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleVersion" }
lifecycle-viewModelSavedState = { module = "androidx.lifecycle:lifecycle-viewmodel-savedstate", version.ref = "lifecycleVersion" }
lifecycle-compiler = { module = "androidx.lifecycle:lifecycle-compiler", version.ref = "lifecycleVersion" }

#Kotlin与协程
stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlinVersion" }
reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlinVersion" }
stdlibJdk7 = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk7", version.ref = "kotlinVersion" }
stdlibJdk8 = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlinVersion" }
coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutinesVersion" }
coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutinesVersion" }

#ViewPager
viewpager = { module = "androidx.viewpager:viewpager", version.ref = "viewpager" }
viewpager2 = { module = "androidx.viewpager2:viewpager2", version.ref = "viewpager2" }

#Hilt
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hiltVersion" }
hilt-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hiltVersion" }

#Work
work-runtime = { module = "androidx.work:work-runtime", version.ref = "workVersion" }
work-runtimeKtx = { module = "androidx.work:work-runtime-ktx", version.ref = "workVersion" }

#Navigation
navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "navigationVersion" }
navigation-ui = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "navigationVersion" }
navigation-dynamic = { module = "androidx.navigation:navigation-dynamic-features-fragment", version.ref = "navigationVersion" }
navigation-dynamicRuntime = { module = "androidx.navigation:navigation-dynamic-features-runtime", version.ref = "navigationVersion" }
navigation-testing = { module = "androidx.navigation:navigation-testing", version.ref = "navigationVersion" }

#DataStore
datastore-core = { module = "androidx.datastore:datastore-core", version.ref = "dataStoreVersion" }
datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "dataStoreVersion" }

#测试
junit = { module = "junit:junit", version.ref = "junit" }
androidJunit = { module = "androidx.test.ext:junit", version.ref = "androidJunit" }
espresso = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" }

#第三方依赖库
#ARouter
arouter-core = { module = "com.github.jadepeakpoet.ARouter:arouter-api", version.ref = "aRouterVersion" }
arouter-compiler = { module = "com.github.jadepeakpoet.ARouter:arouter-compiler", version.ref = "aRouterVersion" }

#Retrofit
retrofit-core = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofitVersion" }
retrofit-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofitVersion" }
gson = { module = "com.google.code.gson:gson", version.ref = "gsonVersion" }
gsonFactory = { module = "com.github.getActivity:GsonFactory", version.ref = "gsonFactoryVersion" }

#图片加载
glide-core = { module = "com.github.bumptech.glide:glide", version.ref = "glideVersion" }
glide-annotation = { module = "com.github.bumptech.glide:annotations", version.ref = "glideVersion" }
glide-integration = { module = "com.github.bumptech.glide:okhttp3-integration", version.ref = "glideVersion" }
glide-compiler = { module = "com.github.bumptech.glide:compiler", version.ref = "glideVersion" }
gifDrawable = "pl.droidsonroids.gif:android-gif-drawable:1.2.28"


#Gradle插件
androidGradlePlugin = { module = "com.android.tools.build:gradle", version.ref = "androidGradlePlugin" }
kotlin-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlinVersion" }
hilt-plugin = { module = "com.google.dagger:hilt-android-gradle-plugin", version.ref = "hiltVersion" }
arouter-plugin = { module = "com.github.jadepeakpoet.ARouter:arouter-register", version.ref = "aRouterVersion" }


[bundles]
appcompatBundles = ["appcompat", "supportV4", "coreKtx", "activityKtx", "fragment", "fragmentKtx", "multidex"]
lifecycleBundles = ["lifecycle-runtime", "lifecycle-runtimektx", "lifecycle-livedata", "lifecycle-livedataKtx", "lifecycle-viewModel", "lifecycle-viewModelKtx", "lifecycle-viewModelSavedState"]
widgetBundles = ["constraintLayout", "recyclerView", "cardView", "material", "viewpager", "viewpager2"]
kotlinBundles = ["stdlib", "reflect", "stdlibJdk7", "stdlibJdk8", "coroutines-core", "coroutines-android"]
navigationBundles = ["navigation-fragment", "navigation-ui", "navigation-dynamic", "navigation-dynamicRuntime"]
dataStoreBundles = ["datastore-core", "datastore-preferences"]
retrofitBundles = ["retrofit-core", "retrofit-gson", "gson", "gsonFactory"]
glideBundles = ["glide-core", "glide-annotation", "glide-integration"]
workBundles = ["work-runtime", "work-runtimeKtx"]


[plugins]
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
jetbrains-kotlin = { id = "org.jetbrains.kotlin.android", version.ref = "kotlinVersion" }

Catalogs 配合 Gradle.kts 使用:

bash 复制代码
plugins {
      alias(libs.plugins.android.application) apply false
      alias(libs.plugins.jetbrains.kotlin) apply false
}
ini 复制代码
plugins {
      alias(libs.plugins.android.application)
      alias(libs.plugins.jetbrains.kotlin)
}

android {
      namespace = "com.hgm.versioncatlogsguide"
      compileSdk = 34

      defaultConfig {
            applicationId = "com.hgm.versioncatlogsguide"
            minSdk = 26
            targetSdk = 33
            versionCode = 1
            versionName = "1.0"

            testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
            vectorDrawables {
                  useSupportLibrary = true
            }
      }

      buildTypes {
            release {
                  isMinifyEnabled = false
                  proguardFiles(
                        getDefaultProguardFile("proguard-android-optimize.txt"),
                        "proguard-rules.pro"
                  )
            }
      }
      compileOptions {
            sourceCompatibility = JavaVersion.VERSION_1_8
            targetCompatibility = JavaVersion.VERSION_1_8
      }
      kotlinOptions {
            jvmTarget = "1.8"
      }
      buildFeatures {
            compose = true
      }
      composeOptions {
            kotlinCompilerExtensionVersion = "1.4.3"
      }
      packaging {
            resources {
                  excludes += "/META-INF/{AL2.0,LGPL2.1}"
            }
      }
}

dependencies {

    implementation(libs.bundles.appcompatBundles)
    implementation(libs.bundles.kotlinBundles)
    implementation(libs.bundles.widgetBundles)
    implementation(libs.bundles.lifecycleBundles)
    kapt(libs.lifecycle.compiler)

    ...

}

具体可以参考 Google 的 nowinandroid 项目,内部带有 Catalogs + gradle.kts 的组件化示例。

只不过它没有对 Catalogs 的依赖进行组件化的再封装,因为这个 Demo 也只有几个组件,在我们真实的中大型项目中,都是动辄十几个或几十个组件,如果我们每个组件都需要写一些重复的代码,那么任务量也是很大的,如果要修改某一个配置那也是需要每个组件都修改,岂不是烦死个人,所以我更推荐封装之后使用。

三、使用 Catalog 的进行组件化封装

像我们之前的 config.gradle 一样的方式,我们可以通过自定义 gradle 文件的方式,然后在各组件中引入对应的依赖即可。

从Gradle 7.0开始, Groovy DSL 也可以使用与 Kotlin DSL 类似的依赖语法,这种改进被称为Gradle箭头语法(Gradle Sugar Syntax)。

之前我们的依赖方式:

arduino 复制代码
dependencies {
    implementation 'androidx.appcompat:appcompat:1.4.2'
}

现在我们也能类似 Kotlin DSL 的方式:

scss 复制代码
dependencies {
    implementation libs.arouter.core  
    implementation(libs.bundles.kotlin)
}

并且部分的 Groovy API 支持 DSL 的方法也支持跳转,并且对于 Catalogs 的支持也是很好可以直接跳转过去,但是支持有限,有些可以有些不性感,这是由于 Groovy 是一种动态语言,而 Kotlin 是一种静态语言,它们在IDE集成支持方面存在差异。即使在Gradle 8.0+ 中, Groovy DSL的导航和重构支持仍然有限的原因 。Kotlin DSL作为一种静态类型的DSL,天生得到了更好的 IDE 集成支持。

3.1 使用 BuildSrc + Groovy DSL 的方式

我们就配合 Groovy DSL 的方式来封装一个基类的公共配置:

configBasic.gradle

scss 复制代码
/**
 * 最基类的,公共的配置模块,(一般不直接使用),只是给别的配置文件继承使用
 */
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply plugin: 'org.jetbrains.kotlin.android'
apply plugin: 'dagger.hilt.android.plugin'

android {

    compileSdk = Integer.parseInt(libs.versions.compileSdk.get())
    defaultConfig {
        minSdk = Integer.parseInt(libs.versions.minSdk.get())
        targetSdk = Integer.parseInt(libs.versions.targetSdk.get())
        versionCode = Integer.parseInt(libs.versions.versionCode.get())
        versionName = libs.versions.versionName.get()

        testInstrumentationRunner = libs.versions.testInstrumentationRunner.get()
        vectorDrawables {
            useSupportLibrary = true
        }

        multiDexEnabled = true
    }

    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }

    kotlinOptions {
        jvmTarget = "17"
    }

    buildFeatures {
        buildConfig = true
        viewBinding = true
    }

    packaging {
        resources {
            excludes += "/META-INF/{AL2.0,LGPL2.1}"
        }
    }
}

kapt {
    arguments {
        arg("AROUTER_MODULE_NAME", project.getName())
    }
}

dependencies {
    //基础
    implementation(libs.bundles.appcompatBundles)
    implementation(libs.bundles.kotlinBundles)
    implementation(libs.bundles.widgetBundles)
    implementation(libs.bundles.lifecycleBundles)
    kapt(libs.lifecycle.compiler)

    //Hilt
    implementation(libs.hilt.android)
    kapt(libs.hilt.compiler)

    //ARouter
    implementation(libs.arouter.core)
    kapt(libs.arouter.compiler)

    //junit
    testImplementation(libs.junit)
    androidTestImplementation(libs.androidJunit)
    androidTestImplementation(libs.espresso)
}

子模块的gradle基类,configModule.gradle:

csharp 复制代码
/**
 * 默认的模块初始化gradle配置,可以依赖这个配置 再配置别的依赖
 * 上层的配置和running_config同级别-供组件化开发中的子组件依赖,如auth-component组件
 */
apply plugin: 'com.android.library'

apply from: rootProject.file('configBasic.gradle')  //重复的配置统一由基类提供

dependencies {
    //Module模块默认添加Service模块的
    implementation(project(":cs-service"))
}

主要添加组件的服务类。

app宿主或独立运行模块需要的依赖,configRunning.gradle:

arduino 复制代码
/**
 * 运行模块初始化gradle配置,可以依赖这个配置 再配置别的依赖
 * 上层的配置和module_config同级别-供真正能运行的模块配置,如app组件
 */
apply plugin: 'com.android.application'
apply plugin: 'com.alibaba.arouter'

apply from: rootProject.file('configBasic.gradle')  //重复的配置统一由基类提供

android {
    defaultConfig {
        applicationId = "com.newki.running"
    }

    signingConfigs {
        release {
            storeFile file("${project.rootDir}/${project.store_file}")
            storePassword project.store_password
            keyAlias project.key_alias
            keyPassword project.key_password
            v1SigningEnabled true
            v2SigningEnabled true
        }
    }

    buildTypes {
        release {
            //默认系统混淆
            minifyEnabled true
            // 不显示Log
            buildConfigField "boolean", "LOG_DEBUG", "false"
            //是否可调试
            debuggable false
            //Zipalign优化
            zipAlignEnabled true
            //移除无用的resource文件
            shrinkResources true
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
            signingConfig signingConfigs.release
        }
        debug {
            debuggable true
        }
    }
}

dependencies {
    //Module模块默认添加Service模块的
    implementation(project(":cs-service"))
}

主要是配置一些签名,编译信息。

使用也比较简单:

app的build.gradle:

scss 复制代码
apply from: rootProject.file('configRunning.gradle')

android {
    namespace = "com.newki.template"
    defaultConfig {
        applicationId = libs.versions.applicationId.get()
    }
}

dependencies {
    implementation(project(":cpt-auth"))
    implementation(project(":cpt-profile"))

    //依赖到对应组件的Api模块
    implementation(project(":app-api"))
}

profile组件的build.gradle:

csharp 复制代码
apply from: rootProject.file('configModule.gradle')

android {
    namespace = "com.newki.profile"
}

dependencies {
    //依赖到对应组件的Api模块
    implementation(project(":cpt-profile-api"))
}

3.2 使用 BuildSrc + Kotin DSL 的方式

理论上 Catalog + Kotlin DSL 的方式会更加优雅一些。

例如创建 common.gradle.kts :

javascript 复制代码
// 示例共通配置
tasks.register("commonTask") {
    doLast {
        //做一些公共的配置
    }
}

这里的公共配置和我们之前的BuildSrc中的 DefaultGradlePlugin 配置类似,然后我们在各模块引入这个基类

ini 复制代码
apply(from = "../common.gradle.kts")

又或者使用 Groovy DSL 的类似方式:

configBasic.gradle.kts

scss 复制代码
plugins {
    id("com.android.library")
    kotlin("android")
    kotlin("kapt")
    id("dagger.hilt.android.plugin")
}

android {
    //...配置代码
}

dependencies {
    //...依赖配置
}

外部可以依赖:

ini 复制代码
apply(from = rootProject.file("configBasic.gradle.kts"))

按道理也是能达到同样的效果的,但是目前貌似并不支持这么写,我并没有实现,或者说我见识比较少不知道怎么实现,所以本文是根据 build.gradle 的 Groovy DSL 实现的。当然如果有大佬知道怎么使用 Catalogs + Kotlin DS 的方式封装基类也希望大佬能指点一二。

总结

本文介绍了三种比较推荐的做法,BuildSrc + Kotlin DSL ,Catalogs + Groovy DSL,Catalogs + Kotlin DSL并且这三种方式各有利弊。

BuildSrc + Kotlin DSL 的方案的主要特点是编译会稍慢,可能需要处理依赖版本冲突问题,好处是熟悉的Kotlin语法,与良好的代码导航支持。

Catalogs + Groovy DSL 的方案特点是虽然支持了代码导航,但是支持程度没有 Kotlin DSL 好,好处是编译速度更快,都在一个 Project 中版本冲突问题会缓解。

Catalogs + Kotlin DSL 的方案特点中和个上面两种方案的优点,无需新项目编译,完美的代码导航,友好的语言环境,但是无法像上面两种方案进行封装使用。

如果你的项目是小项目,或者组件比较少,或者不是组件化项目,我推荐你使用 Catalogs + Kotlin DSL 的方式,相对更完美,只需要在每一个模块都写一份配置文件而已。

如果你的项目是大型项目,并且有很多的子组件,那么我推荐你使用 Catalogs + Groovy DSL 的方式,封装一份 gradle 基类方便统一集中的管理。

回归到标题上,Android版本管理BuildSrc过时了?Catalogs才是版本答案?我不信!好吧现在我信了,如今 Catalogs 更新迭代到现在,不管是代码导航还是版本升级提示都表现相对更完美,如果你有新项目开发或者老项目改造我都还是更推荐 Catalogs 的方案。

当然了,这并不是唯一答案,反正都能用,各位高工大佬爱用哪个用哪个,说到底只是一个编译工具,对最终的打包产物与性能并没有影响,我们应该把更多的关注点放在业务和性能上面。

好了闲话少说,如果你有其他的更多的更好的实现方式,也希望大家能评论区交流一起学习进步。如果我的文章有错别字,不通顺的,或者代码、注释、有错漏的地方,同学们都可以指出修正。

最后本文源码奉上,恳请各位大佬高工指点 【传送门】

内部的两个分支分别对应了这里的两种方案,大家可以按需进行参考或测试。

如果感觉本文对你有一点的启发和帮助,还望你能点赞支持一下,你的支持对我真的很重要。

Ok,这一期就此完结。

相关推荐
测试工坊2 小时前
Android 视频播放卡顿检测——帧率之外的第二战场
android
Kapaseker4 小时前
一杯美式深入理解 data class
android·kotlin
鹏多多4 小时前
Flutter使用screenshot进行截屏和截长图以及分享保存的全流程指南
android·前端·flutter
Carson带你学Android4 小时前
OpenClaw移动端要来了?Android官宣AI原生支持App Functions
android
黄林晴4 小时前
Android 删了 XML 预览,现在你必须学 Compose 了
android
三少爷的鞋4 小时前
Android 面试系列 | 内存泄露:从"手动配对"到"架构自愈"
android
恋猫de小郭4 小时前
什么 AI 写 Android 最好用?官方做了一个基准测试排名
android·前端·flutter
louisgeek14 小时前
Android MediatorLiveData
android
锋风1 天前
远程服务器运行Android Studio开发aosp源码
android
测试工坊1 天前
Android UI 卡顿量化——用数据回答"到底有多卡"
android