Android的UI框架学习日记(1)
本文主要分析第一个示例App的工程结构
1. 背景
作为Android工程架构的一员,我主要负责Gradle构建体系的设计与维护。然而,作为一名偏底层工具开发者,我发现自己在实际业务开发方面仍有较大空白,甚至尚未完整编写过一个标准的Activity。
本系列文章将尝试以尽可能底层的视角,系统性还原Android应用从启动到呈现的全链路过程。
2. IDEA搭建工程
用AS直接搭建即可。
setting.gradle
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\.android.*")
includeGroupByRegex("com\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "appdemo"
include ':app'
因为公司项目使用的gradle文件的编写方式比较老,导入插件普遍采用类似这样的方法,对新版本的管理方式比较陌生。
buildscript {
classpath "XX"
}
apply plugin: "xx"
所以这里也聊一下这种 pluginManagement 管理的逻辑。
他实际上相当于让每个 plugin 有自己单独的解析的逻辑,所以不用我们再管 classpath 了。外部的设置好 repositories , 各个子module下面直接使用即可。
plugins {
id "XX" version "YY"
}
这个写法很早就引入了gradle,但是没什么太明显的优点,而且对于大型工程来说,buildscript本来就肯定要写的,所以我们还是坚持buildscript加apply的写法。我个人觉得这样的写法看起来更可预测,所以我也偏爱老写法。
dependencyResolutionManagement 是用来管理统一dependencies的, 这样我们不再需要在每个子项目中再次写这个:
repositories {
maven {
url "XX"
}
}
但我个人感觉这样的管理模式不够有吸引力,还让工程看起来更复杂了,对每个module单独配置更灵活,也好理解。
build.gradle
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
}
这个文件配合gradle/libs.versions.toml
[versions]
agp = "8.8.1"
kotlin = "2.0.0"
coreKtx = "1.16.0"
junit = "4.13.2"
junitVersion = "1.2.1"
espressoCore = "3.6.1"
lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.10.1"
composeBom = "2024.04.01"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
就是用了别名, 然后不用在build.gradle里指定版本, 因为toml里都写好了。
如果能理解原来的管理模式,理解这种新的就不算困难,就是换了一套管理的逻辑,本质还是需要依赖的group name version等属性。然后规定从哪里获取它。
管理的底层源码目前不打算深究, 之后可能会写文档来分析。
app/build.gradle
// 本模块需要用到的plugin
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
// 对AGP的配置
android {
namespace 'com.example.appdemo'
// 这里是在表达我源码中android API可以使用35的接口
compileSdk 35
defaultConfig {
applicationId "com.example.appdemo"
// 要安装我的引用,你的android系统版本至少要是24(只让安装,不保证能跑)
minSdk 24
// 跑应用的时候android会读取这个数值,决定使用的行为变更 targetSdk <= compileSdk
// targetSdk如果比compileSdk大,说明你在看不到新版SDK的情况下说自己做了适配,不合理
targetSdk 35
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
// 这里是true或者false会决定是否开启R8
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
// 非常实用的配置
// source用来表征“我写的代码会用到什么版本的特性”
// target用来表征“我希望我写的代码会被编译成什么版本的字节码”
// 这里随便写不会报错, 但是需要注意生成的target字节码和目标机器的Java接口的兼容性。
// 比如这里我的代码使用了Java11才有的方法isBlank(), 然后编译成了Java8兼容的字节码
// 如果运行时环境时Java11就还是没问题, 但如果是Java8就会报错,java.lang.NoSuchMethodError
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_1_8
}
// kotlin编译器的设置(Android非常推荐使用Koltin替代Java)
// 注意这里的目标必须和targetCompatibility一致
kotlinOptions {
jvmTarget = '1.8'
}
// 用来开启AGP的一些构建特性,比如这里就开启了JetPack Compose
buildFeatures {
compose true
}
}
dependencies {
//主依赖
implementation libs.androidx.core.ktx
implementation libs.androidx.lifecycle.runtime.ktx
implementation libs.androidx.activity.compose
implementation platform(libs.androidx.compose.bom)
implementation libs.androidx.ui
implementation libs.androidx.ui.graphics
implementation libs.androidx.ui.tooling.preview
implementation libs.androidx.material3
// src/test下的代码会用的依赖
testImplementation libs.junit
//src/androidTest
androidTestImplementation libs.androidx.junit
androidTestImplementation libs.androidx.espresso.core
androidTestImplementation platform(libs.androidx.compose.bom)
androidTestImplementation libs.androidx.ui.test.junit4
// buildType是debug的时候会用的依赖
debugImplementation libs.androidx.ui.tooling
debugImplementation libs.androidx.ui.test.manifest
implementation("androidx.appcompat:appcompat:1.6.1")
}
3. UI框架选型
目前android主流的两种UI开发方法
3.1 XML布局 + Java/Koltin控制逻辑
我们通常在XML里写好View,然后在代码中访问。
我们以一个简单的XML文件为例,说一说XML文件的结构:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!" />
</LinearLayout>
第一行指明了当前文件的xml标准以及编码方式
<?xml version="1.0" encoding="utf-8"?>
// 这里的xmlns:android="http://schemas.android.com/apk/res/android"是命名空间
// LinearLayout是一个容器, 后面的android:XX=YY表明了他的属性,vertical表示他是竖直布局
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
// 这是其中的一个View
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!" />
</LinearLayout>
到此一个基本的用XML描述的界面我们就写好了。
现在问题变成了这个描述界面是如何在android上面跑起来的。
从流程上讲,我们的的xml应该会和我们的源码一起打包到package。这个xml是界面,所以他必须放在res/layout下。
因为xml不一定是负责描述UI的,他在android工程里有很多作用。我们放在哪个目录下,决定了aapt会怎么看待我们的xml,所以这里是强制要求而不是建议。
这个xml会被编译到R.layout下,所以我们可以在代码里使用它,类似:
package com.example.appdemo
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
}
}
在Android开发中,有一个看似矛盾但非常有趣的现象:我们经常直接通过R.layout.xx的方式引用资源,而并未显式import R类,且编译器不会报错。这背后的本质在于,R类虽然是由构建工具aapt或aapt2生成的,但编译器和IDE对它进行了特殊处理。
首先,R类位于当前模块的包名下,例如com.example.appdemo.R。由于它与MainActivity处于同一包,编译器允许直接访问而无需显式导入。当然,开发者也可以选择显式导入,它本质上就是一个普通的final类。
更有意思的是,在Android Studio中,我们可以在创建XML布局文件后,立即使用R.layout.new_layout而IDE不会报错,哪怕此时还未触发真正的Gradle构建任务。这是因为Android Studio并不依赖实际生成的R.java或R.class文件,而是通过资源索引系统对res目录进行实时扫描,并构建内存中的资源符号模型。该模型使得IDE能够提供代码补全、跳转、静态分析等能力,即便底层还未真正生成对应的R字节码。
因此,我们在开发阶段所见的R.layout.xx更多是由IDE的索引系统支撑,而非真实存在的class文件。是否报错的关键,并不取决于R的物理存在,而是IDE是否成功解析了资源索引与符号映射。
另一个经常看见网上有博客告诉你你在哪里可以看到R.java。实际上现在已经不会显式的生成R.java了,而是生成class。
事实上,R.class 会在 compileDebugJavaWithJavac这个task中生成,我们可以在build/intermediates/compile_and_runtime_not_namespaced_r_class_jar/debug/processDebugResources/R.jar找到
./gradlew compileDebugJavaWithJavac --info
至于添加一个按钮,findViewById 这种暂时就不再赘述。
3.2 Compose
Compose目前是Android官方主推的编程方式,用AS新创建一个Android工程也是默认采用了Compose框架。
Compose几乎摈弃了XML来设计UI这种模式,可以直接在源码里写控制逻辑和UI样式。
我们使用gradle构建的工程,这个地方就是compose的开关。
android {
buildFeatures {
// 这里设置为True
compose true
}
}
所以实际上要启用compose,我们需要做两件事
- 引入依赖(implementation XX)
- 开启Compose支持(在AGP里面设置)
这里我们就发现了这个框架的特别之处。因为我们发现使用这个框架竟然不是仅仅导入依赖就可以了,还要设置AGP的extension。
这和Compose的底层实现有关。他和XML的写法完全走的是不同的渲染逻辑,比较复杂。
这里的设置实际上和kotlin编译器有关,会注册一些行为,比如识别@Composable这样的注解,这里不再深入展开。
这里展示一段示例代码
package com.bytedance.appdemo
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.bytedance.appdemo.ui.theme.AppDemoComposeTheme
// 这里不再是AppCompactActivity而是ComponentActivity,他是compose框架的Activity
class MainActivity : ComponentActivity() {
// Bundle是Activity的状态, 用于它优雅的重建
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 沉浸界面,可以让你的界面内容在状态栏和导航栏下面
enableEdgeToEdge()
// compose的入口点, 通过lambda实现类似HTML的嵌套结构
setContent {
// 自动生成的类,统一颜色、字体等UI样式
AppDemoComposeTheme {
// 页面结构容器
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
// 组件
Greeting(
name = "Android",
modifier = Modifier.padding(innerPadding)
)
}
}
}
}
}
//用@Composable标记,组件实现
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Text(
text = "Hello $name!",
modifier = modifier
)
}
//与App无关,用于IDE预览UI, 点击右上角split可以看到预览图
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
AppDemoComposeTheme {
Greeting("Android")
}
}