Jetpack Compose Navigation 2.x 详解

简单的页面跳转

在 Compose 中,我们可以借助 State 实现一个非常简单的屏幕内容切换效果。

kotlin 复制代码
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            ComposeNavigationTestTheme {
                MyApp()
            }
        }
    }
}

@Composable
fun MyApp(modifier: Modifier = Modifier) {
    // 当前显示的页面
    var currentScreen by remember { mutableIntStateOf(0) }

    when (currentScreen) {
        0 -> ScreenA(modifier = modifier, onClick = {
            currentScreen = 1
        })

        1 -> ScreenB(modifier = modifier, onClick = {
            currentScreen = 2
        })

        2 -> ScreenC(modifier = modifier, onClick = {
            currentScreen = 0
        })
    }
}

@Composable
fun ScreenA(modifier: Modifier = Modifier, onClick: () -> Unit) {
    Column(
        modifier = modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text("这里是页面 A")
        Button(onClick = onClick) {
            Text("跳转到页面 B")
        }
    }
}

@Composable
fun ScreenB(modifier: Modifier = Modifier, onClick: () -> Unit) {
    Column(
        modifier = modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text("这里是页面 B")
        Button(onClick = onClick) {
            Text("跳转到页面 C")
        }
    }
}

@Composable
fun ScreenC(modifier: Modifier = Modifier, onClick: () -> Unit) {
    Column(
        modifier = modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text("这里是页面 C")
        Button(onClick = onClick) {
            Text("跳转到页面 A")
        }
    }
}

运行效果:

但当前的效果只有页面切换,没有页面导航。按下返回键时,只会回到桌面,无法回到上一个页面。

当然,你可以自己来实现导航功能,但我们更多会使用 Navigation 组件。

添加依赖

build.gradle.kts 文件中添加依赖:

kotlin 复制代码
dependencies {
    implementation("androidx.navigation:navigation-compose:2.9.6")
}

简单用法

修改 MyApp 函数:

kotlin 复制代码
const val SCREEN_A = "screen_a"
const val SCREEN_B = "screen_b"
const val SCREEN_C = "screen_c"

@Composable
fun MyApp(modifier: Modifier = Modifier) {
    val navController = rememberNavController()

    NavHost(navController = navController, startDestination = SCREEN_A, modifier = modifier) {
        // 路由字符串映射到Composable页面
        composable(route = SCREEN_A) {
            ScreenA {
                // 跳转
                navController.navigate(route = SCREEN_B)
            }
        }
        composable(route = SCREEN_B) {
            ScreenB {
                navController.navigate(route = SCREEN_C)
            }
        }
        composable(route = SCREEN_C) {
            ScreenC {
                navController.navigate(route = SCREEN_A)
            }
        }
    }
}

我们定义了每个页面的路由,设置起始路由为 SCREEN_A,也就是启动页为 ScreenA

这样就具备导航功能了,运行效果:

当前所在页面

要知道当前所在页面,可以使用 NavDestination 获取当前页面的路由。

kotlin 复制代码
val navController = rememberNavController()
navController.currentDestination?.route

在 Compose 中,我们更多会使用 NavController.currentBackStackEntryAsState(),因为它返回的是一个 State

kotlin 复制代码
val navController = rememberNavController()

val currentBackStack by navController.currentBackStackEntryAsState()
currentBackStack?.destination?.route

启动模式

跳转的默认行为对应的是 Activity 中的 Standard 启动模式。

我们还可以在跳转时指定额外的行为。

popUpTo

kotlin 复制代码
// 假设在 ScreenC 中跳转到 ScreenA
navController.navigate(route = SCREEN_A) {
    popUpTo(route = SCREEN_A) {
        inclusive = true
    }
}

上面的代码表示在跳转到 ScreenA 之前,会不断让页面出栈,直到遇到传入 popUpTo() 函数的路由参数对应的页面(ScreenA),将 inclusive(包括)属性设为 true,那么在遇到目标页面时,也会将目标页面弹出栈。

这个组合常用于模拟 Activity 的 singleTask 启动模式的栈行为。

比如当前的返回栈是 A -> B -> C,如果从 C 跳回 A,我们希望清除掉 A 上面的 BC,并且不希望有两个 A(销毁旧的 A),就可以使用它。

但请注意,这和 Activity 的 singleTask 行为并不完全一致,具体可以看我的这篇博客:精通 Activity 四大启动模式

launchSingleTop

launchSingleTop 属性的效果类似于 Activity 的 singleTop 启动模式。

kotlin 复制代码
composable(route = SCREEN_A) {
    ScreenA {
        navController.navigate(route = SCREEN_A) {
            launchSingleTop = true
        }
    }
}

当指定 launchSingleToptrue 时,NavController 会判断返回栈的顶部是否为导航目标页。如果是,NavController 就不会创建新的实例,而是会复用栈顶的实例。

避免了在栈顶多次创建相同的页面实例。

跳转时传递参数

必选参数

首先,修改 ScreenB,让它能够接收字符串参数 userId

kotlin 复制代码
@Composable
fun ScreenB(modifier: Modifier = Modifier, userId: String? = null, onClick: () -> Unit = {}) {
    Column(
        modifier = modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text("这里是页面 B")
        Text(text = "用户id为:$userId")
        Button(onClick = onClick) {
            Text("跳转到页面 C")
        }
    }
}

接着修改 NavHost,使得路由节点能够支持参数传递。

kotlin 复制代码
composable(
    // 新增userId路由参数 
    route = "${SCREEN_B}/{userId}",
    arguments = listOf(
        // 指定路由参数的类型
        navArgument(name = "userId") {
            type = NavType.StringType
        }
    )
) { navBackStackEntry ->
    // 获取参数数据
    val userId = navBackStackEntry.arguments?.getString("userId")
    ScreenB(userId = userId) {
        navController.navigate(route = SCREEN_C)
    }
}

最后,在页面跳转时,将参数传递进来。

kotlin 复制代码
composable(route = SCREEN_A) {
    ScreenA {
        val userId = "123"
        navController.navigate(route = "${SCREEN_B}/$userId")
    }
}

运行效果:

可选参数

Navigation 也支持可选参数,类似于 URL 的查询参数。

修改 NavHost 中 ScreenB 的路由定义:

kotlin 复制代码
composable(
    // 定义可选参数
    route = "${SCREEN_B}?userId={userId}",
    arguments = listOf(
        navArgument(name = "userId") {
            type = NavType.StringType
            nullable = true // 必须要允许为空
            // defaultValue = "0" // 可提供默认值
        }
    )
) { navBackStackEntry ->
    val userId = navBackStackEntry.arguments?.getString("userId") // 可能为 null
    ScreenB(userId = userId) {
        navController.navigate(route = SCREEN_C)
    }
}

跳转时,可以携带参数,也可以不传入:

kotlin 复制代码
composable(route = SCREEN_A) {
    ScreenA {
        // 带参数跳转
        navController.navigate(route = "${SCREEN_B}?userId=456")

        // 不带参数跳转
        navController.navigate(route = SCREEN_B)
    }
}

页面间返回结果

A 跳转到 B,如果还需要在 B 页面返回一个结果给 A,我们可以使用 SavedStateHandle 来完成

修改 ScreenB,使其能够返回结果:

kotlin 复制代码
@Composable
fun ScreenB(modifier: Modifier = Modifier, onClick: (String) -> Unit = {}) {
    Column(
        modifier = modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text("这里是页面 B")
        Button(onClick = { onClick("这是B返回的结果") }) {
            Text("返回结果")
        }
    }
}

修改 NavHostScreenB 的路由定义:

kotlin 复制代码
composable(
    route = SCREEN_B
) { navBackStackEntry ->
    ScreenB { result ->
        // 将结果设置到上一个页面的SavedStateHandle中
        navController.previousBackStackEntry
            ?.savedStateHandle
            ?.set("result_key", result)
        // 回到上一个页面
        navController.popBackStack() // 将当前页面弹出栈
    }
}

现在,就能够在 ScreenA 中接收结果了:

kotlin 复制代码
composable(route = SCREEN_A) { navBackStackEntry ->

    // 这样也能获取到当前的栈节点
    // navController.currentBackStackEntry

    // 监听栈节点的参数
    val result = navBackStackEntry.savedStateHandle.getStateFlow<String?>(
        key = "result_key",
        initialValue = null
    ).collectAsState()

    ScreenA(resultFromB = result.value) {
        // 移除参数
        navBackStackEntry.savedStateHandle.remove<String>("result_key")

        navController.navigate(route = SCREEN_B)
    }
}

@Composable
fun ScreenA(modifier: Modifier = Modifier, resultFromB: String?, onClick: () -> Unit) {
    Column(
        modifier = modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text("这里是页面 A")
        resultFromB?.let {
            Text("从B返回的结果: $it")
        }
        Button(onClick = onClick) {
            Text("跳转到页面 B")
        }
    }
}

高级导航技巧

当应用变得复杂时,我们可以嵌套导航图来让路由更加清晰。

kotlin 复制代码
const val SCREEN_A = "screen_a"
const val SCREEN_B = "screen_b"
const val AUTH_FLOW_ROUTE = "auth_flow"
const val LOGIN_ROUTE = "login"
const val REGISTER_ROUTE = "register"

@Composable
fun MyComplexApp(modifier: Modifier = Modifier) {
    val navController = rememberNavController()
    NavHost(navController = navController, startDestination = SCREEN_A) {
        // 主应用页面
        composable(route = SCREEN_A) {
            Column(
                modifier = Modifier.fillMaxSize(),
                horizontalAlignment = Alignment.CenterHorizontally,
                verticalArrangement = Arrangement.Center
            ) {
                Text("A")
                Button(onClick = {
                    // 将跳转到子图的起始页 (LOGIN_ROUTE)
                    navController.navigate(route = AUTH_FLOW_ROUTE)
                }) {
                    Text("跳转到登录页")
                }
            }
        }
        composable(route = SCREEN_B) {
            Text("B")
        }

        // 子导航图-认证流程
        navigation(
            startDestination = LOGIN_ROUTE, // 子图的起始页
            route = AUTH_FLOW_ROUTE         // 子图的路由名
        ) {
            composable(route = LOGIN_ROUTE) {
                Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
                    Text("Login")
                }
            }
            composable(route = REGISTER_ROUTE) {
                Text("Register")
            }
        }
    }

}

导航完成时,回退栈将会是 [ SCREEN_A, LOGIN_ROUTE ]

ViewModel 作用域

默认情况下,ViewModel 的生命周期绑定到了 NavBackStackEntry 返回栈节点。

如果我们希望多个页面(Composable)共享一个 ViewModel,我们可以提升 ViewModel 的作用域到这些屏幕共同的嵌套导航图。

比如我们希望登录和注册页面能够共享一个邮箱数据,我们可以这样做:

kotlin 复制代码
class SharedAuthViewModel : ViewModel() {

    // 邮箱
    private val _email = MutableStateFlow("")
    val email: StateFlow<String> = _email

    fun updateEmail(newEmail: String) {
        _email.value = newEmail
    }

    init {
        Log.d("AuthViewModel", "ViewModel created!")
    }

    override fun onCleared() {
        super.onCleared()
        Log.d("AuthViewModel", "ViewModel destroyed!")
    }
}

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            ComposeNavigationTestTheme {
                MyApp()
            }
        }
    }
}

const val HOME_ROUTE = "home"
const val AUTH_FLOW_ROUTE = "auth_flow" // 嵌套图的路由
const val LOGIN_ROUTE = "login"
const val REGISTER_ROUTE = "register"

@Composable
fun MyApp() {
    val navController = rememberNavController()

    NavHost(navController = navController, startDestination = HOME_ROUTE) {
        composable(HOME_ROUTE) {
            HomeScreen {
                navController.navigate(AUTH_FLOW_ROUTE)
            }
        }

        // 认证流程子图
        navigation(
            startDestination = LOGIN_ROUTE,
            route = AUTH_FLOW_ROUTE
        ) {
            composable(LOGIN_ROUTE) { navBackStackEntry ->
                // 获取子图的 NavBackStackEntry
                val authGraphEntry = remember(navBackStackEntry) {
                    val parentRoute = navBackStackEntry.destination.parent!!.route!!
                    navController.getBackStackEntry(parentRoute)
                }
                // 将 ViewModel 作用域限定到这个子图
                val authViewModel: SharedAuthViewModel = viewModel(authGraphEntry)

                LoginScreen(
                    viewModel = authViewModel,
                    toRegister = { navController.navigate(REGISTER_ROUTE) },
                    toHome = {
                        // 回到主页
                        navController.popBackStack(HOME_ROUTE, inclusive = false)
                    })
            }

            composable(REGISTER_ROUTE) { navBackStackEntry ->
                val authGraphEntry = remember(navBackStackEntry) {
                    val parentRoute = navBackStackEntry.destination.parent!!.route!!
                    navController.getBackStackEntry(parentRoute)
                }
                // 与 LoginScreen 获取到完全相同的 ViewModel 实例
                val authViewModel: SharedAuthViewModel = viewModel(authGraphEntry)

                RegisterScreen(viewModel = authViewModel) {
                    // 回到登录页
                    navController.navigate(LOGIN_ROUTE) {
                        popUpTo(route = LOGIN_ROUTE) { 
                            inclusive = true
                        }
                    }
                    // 或者直接使用 navController.popBackStack()
                }
            }
        }
    }
}

@Composable
fun HomeScreen(onClick: () -> Unit = {}) {
    Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text("主屏幕")
        Button(onClick = onClick) {
            Text("进入登录/注册流程")
        }
    }
}

@Composable
fun LoginScreen(
    viewModel: SharedAuthViewModel,
    toRegister: () -> Unit,
    toHome: () -> Unit
) {
    val email by viewModel.email.collectAsState()

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text("登录页")
        TextField(
            value = email,
            onValueChange = { viewModel.updateEmail(it) },
            label = { Text("共享的 Email") }
        )
        Spacer(Modifier.height(16.dp))
        Button(onClick = toRegister) {
            Text("跳转到注册页")
        }
        Spacer(Modifier.height(8.dp))
        Button(onClick = toHome) {
            Text("返回主页")
        }
    }
}

@Composable
fun RegisterScreen(viewModel: SharedAuthViewModel, toLogin: () -> Unit) {
    val email by viewModel.email.collectAsState()

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text("注册页")
        TextField(
            value = email,
            onValueChange = { viewModel.updateEmail(it) },
            label = { Text("共享的 Email (与登录页同步)") }
        )
        Spacer(Modifier.height(16.dp))
        Button(onClick = toLogin) {
            Text("返回登录页")
        }
    }
}

popBackStack(route, inclusive) 的行为是不断从栈顶弹出页面,直到找到函数参数 route,如果 inclusive 标志为 true,那么 route 也会一并弹出。

底部导航栏的状态保存与恢复

在实现底部导航栏时,切换 Tab 往往会导致前一个 Tab 的状态(如滚动位置)丢失。

为了实现状态的保存与恢复,我们可以使用三个关键选项:

kotlin 复制代码
// 当点击底部 Tab 时,调用此方法
fun NavController.onBottomNavClicked(route: String) {
    this.navigate(route) {
        // 弹出到图的起始点,避免回退栈无限累积
        popUpTo(this@onBottomNavClicked.graph.findStartDestination().id) {
            saveState = true // 关键:弹出时保存栈中页面的状态
        }
        // 避免在已位于栈顶时重复创建
        launchSingleTop = true
        // 关键:导航到目的地时,恢复其之前保存的状态
        restoreState = true
    }
}

这样能实现状态不丢失,并且在 首页 -> 搜索 -> 我的 来回切换时,回退栈只会是这几个:首页首页 -> 搜索首页 -> 我的

示例代码:

kotlin 复制代码
class TabViewModel(private val tabName: String) : ViewModel() {
    private val _count = MutableStateFlow(0)
    val count = _count.asStateFlow()

    fun increment() {
        _count.value++
    }

    init {
        Log.d("ViewModelLifecycle", "$tabName ViewModel CREATED")
    }

    override fun onCleared() {
        Log.d("ViewModelLifecycle", "$tabName ViewModel CLEARED")
        super.onCleared()
    }
}

// 底部 Tab 的路由
sealed class Screen(val route: String, val icon: ImageVector, val label: String) {
    data object Home : Screen("home", Icons.Filled.Home, "首页")
    data object Search : Screen("search", Icons.Filled.Search, "搜索")
    data object Profile : Screen("profile", Icons.Filled.Person, "我的")
}

val bottomNavItems = listOf(
    Screen.Home,
    Screen.Search,
    Screen.Profile
)


class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            ComposeNavigationTestTheme {
                MainScreen()
            }
        }
    }
}

@Composable
fun MainScreen() {
    val navController = rememberNavController()
    Scaffold(
        bottomBar = {
            MyBottomNavBar(navController = navController)
        }
    ) { paddingValues ->
        NavHost(
            navController = navController,
            startDestination = Screen.Home.route,
            modifier = Modifier.padding(paddingValues)
        ) {
            composable(Screen.Home.route) {
                // 手动创建 factory 来传递 tabName,以便观察日志
                val vm: TabViewModel = viewModel(factory = TabViewModelFactory("Home"))
                TabScreenContent(vm)
            }
            composable(Screen.Search.route) {
                val vm: TabViewModel = viewModel(factory = TabViewModelFactory("Search"))
                TabScreenContent(vm)
            }
            composable(Screen.Profile.route) {
                val vm: TabViewModel = viewModel(factory = TabViewModelFactory("Profile"))
                TabScreenContent(vm)
            }
        }
    }
}

class TabViewModelFactory(private val tabName: String) : ViewModelProvider.Factory {
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(TabViewModel::class.java)) {
            @Suppress("UNCHECKED_CAST")
            return TabViewModel(tabName) as T
        }
        throw IllegalArgumentException("Unknown ViewModel class")
    }
}


// 底部导航栏
@Composable
fun MyBottomNavBar(navController: NavController) {
    NavigationBar {
        // 当前路由状态
        val navBackStackEntry by navController.currentBackStackEntryAsState()
        val currentRoute = navBackStackEntry?.destination?.route

        bottomNavItems.forEach { screen ->
            NavigationBarItem(
                selected = currentRoute == screen.route,
                onClick = {
                    navController.navigate(screen.route) {
                        // 弹出到图的起始点,在这里相当于是:
                        // popUpTo(Screen.Home.route) {
                        //     saveState = true
                        //     inclusive = false
                        // }
                        popUpTo(navController.graph.findStartDestination().id) {
                            // 关键:弹出屏幕时,保存它们的状态
                            saveState = true
                        }
                        // 避免在已位于栈顶时重复创建实例
                        launchSingleTop = true
                        // 关键:导航到目的地时,恢复其之前保存的状态
                        restoreState = true
                    }
                },
                icon = { Icon(screen.icon, contentDescription = screen.label) },
                label = { Text(screen.label) }
            )
        }
    }
}

// Tab 屏幕内容
@Composable
fun TabScreenContent(viewModel: TabViewModel) {
    val count by viewModel.count.collectAsState()
    val listState = rememberLazyListState() // 用于演示滚动位置的保存

    Column(modifier = Modifier.fillMaxSize()) {
        Text(text = "当前计数值: $count", style = MaterialTheme.typography.headlineMedium)
        Button(onClick = { viewModel.increment() }) {
            Text("点我 +1")
        }

        LazyColumn(state = listState) {
            items(100) {
                Text(text = "我是列表项 $it", modifier = Modifier.padding(16.dp))
            }
        }
    }
}

在这里起始页是 home,假设当前的返回栈是 [home, profile],点击 search 标签页时将会发生:

  • popUpTo(navController.graph.findStartDestination().id):将除了起始页(home)的页面都弹出栈,返回栈会变为 [home]。(inclusive 默认为 false

  • saveState = true:将弹出的页面的 ViewModel 和 UI 状态进行保存。

  • launchSingleTop = true:防止重复点击同一个 Tab 时,创建多个页面实例。

  • restoreState = true:导航到 search 页时,会先看看之前有没有为 search 页保存过状态。

    • 如果有,会直接唤醒之前的 search 实例,恢复其 ViewModel 和 UI 状态(这里是滚动位置)。
    • 如果没有,意味着是第一次点击,会创建一个全新的 search 页面实例。

deep link 就是可以让第三方(如浏览器、其他应用等)直接唤起指定的页面。

首先,让当前的 Activity 支持 deep link,修改 AndroidManifest.xml 文件:

xml 复制代码
<activity
    android:name=".MainActivity"
    android:exported="true"
    android:theme="@style/Theme.ComposeNavigationTest">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />

        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>

    <intent-filter>
        <action android:name="android.intent.action.VIEW" />

        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        
        <!-- 支持 myapp://screen_b 格式-->
        <data
            android:host="screen_b"
            android:scheme="myapp" />
    </intent-filter>
</activity>

然后在 ScreenB 页面的路由定义处加上 deep link 的支持即可。

kotlin 复制代码
composable(
    route = "${SCREEN_B}/{userId}",
    arguments = listOf(
        navArgument(name = "userId") {
            type = NavType.StringType
        }
    ),
    // 添加 deepLinks 列表
    deepLinks = listOf(
        navDeepLink {
            // 定义 URI 格式,它会自动匹配路由参数
            uriPattern = "myapp://$SCREEN_B/{userId}"
        }
    )
) { navBackStackEntry ->
    val userId = navBackStackEntry.arguments?.getString("userId")
    ScreenB(userId = userId) {
        navController.navigate(route = SCREEN_C)
    }
}

只要进入 "myapp://screen_b/321" 链接,即可在打开页面 B 的同时传递 userId 为 "321" 的参数。

参考

参考郭霖大佬的博客:写给初学者的Jetpack Compose教程,Navigation

相关推荐
冬奇Lab7 小时前
ANR实战分析:一次audioserver死锁引发的系统级故障排查
android·性能优化·debug
冬奇Lab8 小时前
Android车机卡顿案例剖析:从Binder耗尽到单例缺失的深度排查
android·性能优化·debug
ZHANG13HAO8 小时前
调用脚本实现 App 自动升级(无需无感、允许进程中断)
android
圆号本昊9 小时前
【2025最新】Flutter 加载显示 Live2D 角色,实战与踩坑全链路分享
android·flutter
小曹要微笑10 小时前
MySQL的TRIM函数
android·数据库·mysql
mrsyf11 小时前
Android Studio Otter 2(2025.2.2版本)安装和Gradle配置
android·ide·android studio
DB虚空行者11 小时前
MySQL恢复之Binlog格式详解
android·数据库·mysql
liang_jy13 小时前
Android 事件分发机制(一)—— 全流程源码解析
android·面试·源码
峥嵘life14 小时前
2026 Android EDLA 认证相关资源网址汇总(持续更新)
android·java·学习
kkk_皮蛋14 小时前
在移动端使用 WebRTC (Android/iOS)
android·ios·webrtc