1、mvc、mvp和mvvm的区别,总结一下
-
从MVC到MVP:解决“上帝Activity”和“可测试性”
- MVC问题:在Android中,
Activity/Fragment通常身兼View和Controller,导致它们过于庞大(“上帝对象”),且包含大量Android依赖,几乎无法进行单元测试。 - MVP改进:通过引入
Presenter,将所有的展示逻辑从View中剥离。Presenter是纯POJO,可以轻松进行单元测试。View变得被动,只负责展示和转发事件。
- MVC问题:在Android中,
-
从MVP到MVVM:解决“接口爆炸”和“手动同步”
- MVP问题:每个
View都需要定义详细的IView接口,导致接口数量庞大。同时,Presenter需要手动调用view.showXxx()来更新UI,繁琐且易漏。 - MVVM改进:引入
ViewModel和数据绑定(或响应式流)。ViewModel暴露可观察的数据状态(如LiveData<UiState>),View(通过绑定或观察者)自动订阅这些状态并更新。省去了大量接口和手动更新UI的代码,数据驱动UI的理念更清晰。
- MVP问题:每个
2、MMKV中 protobuf的面试题
在MMKV中,Protocol Buffers (Protobuf) 的应用是其性能远超 SharedPreferences 的关键。核心在于其高效的序列化、紧凑的存储格式以及与MMKV追加写入模式的深度适配。
下表总结了MMKV中Protobuf实现的核心要点,你可以快速掌握整体概念:
3、 Protobuf深入技术细节
- 变长编码的实现:
Protobuf的Varint编码,每个字节的最高位是标志位,表示后续是否还有字节。例如,对于小整数300(二进制1 0010 1100),编码后只需2个字节1010 1100 0000 0010,而非4个字节
-
MMBuffer设计:
MMKV内部使用MMBuffer封装数据。对于编码后小于等于10字节的小数据(如整型),直接存储在栈上的paddedBuffer数组中,避免堆内存分配;大数据则存储在堆中。这进一步优化了频繁写入小数据的性能。 -
增量更新流程:
当调用mmkv.encode(“key”, value)时,其流程为:
4、 Protobuf面试回答
“MMKV选用Protobuf主要看中其编码高效和体积小巧。其实现有几个关键点:首先,它利用了Protobuf的Varint变长编码和ZigZag编码来极致压缩数据,特别是对于数值型数据。其次,它的文件格式设计与Protobuf编码深度结合,所有数据都是按
Key长度-Key-Value长度-Value的格式顺序追加,结构简单,解析极快。最重要的是,这个设计完美契合了MMKV的 ‘追加写’核心机制。每次更新数据,MMKV并不修改旧数据,而是直接将新Key-Value对进行Protobuf序列化后追加到内存映射的文件末尾,这避免了随机IO,是写入性能高的根本原因。读取时,则依靠内存中维护的哈希表索引(记录每个Key对应数据的位置偏移和长度)直接定位并解码,速度也很快。当然,只追加会导致文件变大,所以MMKV在适当时机会触发文件重整,这个过程也是遍历所有Key,用Protobuf将最新数据重新紧凑地序列化保存。所以,Protobuf不仅仅是MMKV的一个序列化工具,更是其高性能增量更新存储架构的基石。”
5、lifecycleScope.async { }和 lifecycleScope.launch { }的 区别
这是一个非常核心的协程使用问题。lifecycleScope.async { } 和 lifecycleScope.launch { } 的根本区别在于启动协程的目的和获取结果的方式。结合 lifecycleScope,还涉及生命周期管理和异常处理。
核心区别对比
| 特性 | lifecycleScope.launch { } | lifecycleScope.async { } |
|---|---|---|
| 返回类型 | Job | Deferred<T> |
| 核心用途 | 启动一个不需要返回结果的“任务”。 | 启动一个需要返回结果的“计算”。 |
| 异常处理 | 未捕获异常会立即抛出并传播,可能导致协程作用域取消。 | 异常被安全地存储在 Deferred 对象中,直到调用 await() 时才抛出。 |
| 主要场景 | 执行“副作用”操作,如更新UI、导航、记录日志。 | 并发执行任务并获取结果,或在需要处理异常时提供更安全的包装。 |
6、lifecycleScope.launch { }:执行“任务”
这就像派一个助手去办一件事,你只关心他有没有做完,不关心他带回来什么。
// 场景:执行一个不需要返回值的网络请求
fun fetchDataAndShow() {
lifecycleScope.launch { // 返回一个 Job
val result = apiService.fetchData() // 挂起函数,但结果只在内部使用
updateUI(result) // 在协程内部消化结果
}
// 调用后我们无法,也不需要从外部获取 fetchData 的返回值
}
- 异常处理:如果
apiService.fetchData()抛出异常,且内部未捕获,该异常会立即导致这个launch协程失败,并可能向上传播,取消lifecycleScope中的其他子协程(取决于是否使用SupervisorJob)。
7、lifecycleScope.async { }:执行“计算”
这就像派一个助手去算一道题,你需要他把计算结果带回来给你。
// 场景:并发执行两个网络请求,并组合它们的结果
fun fetchConcurrentData() {
lifecycleScope.launch {
// 启动两个并发计算
val deferredUser = async { apiService.getUserProfile() } // 返回 Deferred<User>
val deferredNews = async { apiService.getLatestNews() } // 返回 Deferred<List<News>>
// 挂起等待两个结果都返回
val user = deferredUser.await()
val news = deferredNews.await()
// 使用组合后的结果
displayUserWithNews(user, news)
}
}
- 关键:你必须调用
await()来获取Deferred中的结果(或异常)。await()是一个挂起函数。 - 安全优势:如果
getUserProfile()失败了,这个异常会被包装在deferredUser中。只有当你调用deferredUser.await()时,这个异常才会被抛出,给你机会用try-catch包裹await()来进行精确处理,而不会意外取消整个父协程。
8、 在 lifecycleScope 中使用的关键注意事项
-
- 生命周期自动取消:无论
launch还是async启动的协程,都会在lifecycleScope关联的Lifecycle(如Activity/Fragment)销毁时自动取消。这意味着await()可能会抛出CancellationException。
- 生命周期自动取消:无论
-
2.避免在
async中不调用await:如果你启动了async但从不调用await(),那么:- 你无法获取结果。
- 如果
async块内发生异常,该异常将被静默吞噬(因为没人触发Deferred抛出它),这会导致难以调试的Bug。
// ❌ 错误示例:启动了 async 但永不 await
fun riskyCall() {
lifecycleScope.async {
throw RuntimeException("这个异常会被静默吞噬!")
}
// 没有 await,上面的异常不会抛出,协程悄无声息地失败
}
- 3. 合理选择构建器:
- 使用
launch:当你只是要执行一个动作(如发起一个网络请求并自动更新UI),且不需要在协程外部获取其返回值时。 - 使用
async:当你需要并发执行多个任务并组合它们的结果,或者需要对可能失败的异步调用进行更精细的异常控制时。
- 使用
8、var job1 = lifecycleScope.launch{},需要调用job1.join()方法吗
在绝大多数情况下,你不需要、也不应该在UI线程中直接调用 job1.join()。 但在某些特殊并发控制场景下,你可能在另一个协程内部调用它。
注意:如果调用了job1.join()就会阻塞当前协程(只能在协程内调用),永远记住,在主线程直接调用 join()或runBlocking() 是危险的。
核心结论如下表所示:
| 场景 | 是否需要调用 job1.join()? | 原因与解释 |
|---|---|---|
绝大多数UI驱动场景 (如:在onCreate中启动一个网络请求) | 不需要 | 你希望它“异步启动,后台运行”,不阻塞UI线程。协程会被 lifecycleScope 自动管理,在Activity/Fragment销毁时自动取消。 |
| 需要严格等待该任务完成 (如:必须在任务A完成后,才能执行任务B) | 可能需要,但需谨慎 | 你必须在另一个协程内调用 job1.join(),以确保执行顺序。直接在主线程调用会阻塞UI,导致ANR。 |
| 结构化并发中的隐式等待 | 由框架自动处理 | 父协程(或作用域)会自动等待所有子协程完成。lifecycleScope 本身作为作用域,会在其销毁流程中隐含这个“等待”。 |
1. 最常见的“不需要调用”场景
你的典型用法应该是这样的,让协程在后台独立运行:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 启动一个协程,但不阻塞当前线程
val job1 = lifecycleScope.launch {
fetchDataAndUpdateUI() // 一个挂起函数
}
// 立即继续执行onCreate的其他代码,无需等待job1完成
setupViews()
}
这里绝对不要调用 job1.join() ,因为 join() 是一个挂起函数,如果在主线程(UI线程)中直接调用,它会阻塞整个UI线程直到 job1 完成,这会导致界面卡顿甚至ANR。
*2. 什么情况下需要调用 join()?如何正确调用?
当你需要协调多个并发协程的执行顺序时。例如,确保任务A完成后再启动任务B。此时,你需要在另一个协程内部调用 join()。
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch {
// 任务A:先加载用户配置
val jobLoadConfig = launch { loadUserConfig() }
// 挂起当前协程,等待【任务A】完成
jobLoadConfig.join()
// 只有上面join()完成后,才会执行到这里
// 任务B:根据配置加载数据
launch { loadDataAccordingToConfig() }
// 任务C:可以与任务B同时进行,无需等待
launch { prefetchOtherData() }
}
}
关键点:jobLoadConfig.join() 挂起的是它所在的外层父协程,而不是UI主线程。因此UI仍然可以流畅响应。
3. lifecycleScope 的自动管理
- 自动等待:
lifecycleScope作为一个协程作用域,遵循结构化并发。当它因Activity/Fragment销毁而取消时,其内部流程会等待所有子协程完成其取消逻辑(处理finally块等)。 - 自动取消:当
Activity进入onDestroy时,lifecycleScope会自动取消,你手动启动的job1也会被自动取消,无需手动调用job1.cancel()。
总结与最佳实践
- 默认不调用:作为基本规则,当你使用
lifecycleScope.launch启动一个协程后,默认不要调用join()。让它异步运行。 - 理解
join()的作用:它用于协程内部的同步控制,而不是在普通代码流中调用。 - 利用结构化并发:如果任务B必须等待任务A,更清晰的写法是直接将任务B的代码放在任务A的协程体后面,或者使用
async/await来获取结果并控制流程。 - 不要阻塞UI线程:永远记住,在主线程直接调用
join()或runBlocking是危险的。
9、协程的执行顺序;
在 Kotlin 协程中,
lifecycleScope.launch启动的协程默认是 并发执行 的。
并发执行指的是系统在宏观上看起来能同时处理多个任务的能力。它不是指微观上“同一时刻”执行多个任务(那是“并行”),而是指通过快速切换和调度,让多个任务在一段时间内都得到推进,从而给人以“同时发生”的错觉。
1、协程中 job1和job2同时执行();
lifecycleScope.launch {
var job1 = launch {
delay(1000)
Log.d("lyy","---job1----:${getCurrentTime()}")
}
var job2 = launch {
delay(1000)
Log.d("lyy","---job2----:${getCurrentTime()}")
}
}
//结果:
lyy: ---job1----:2026年01月23日 17:16:51
lyy: ---job2----:2026年01月23日 17:16:51
2、协程中 job1先执行完,再开始执行job2;
lifecycleScope.launch {
var job1 = launch {
delay(1000)
Log.d("lyy","---job1----:${getCurrentTime()}")
}
job1.join()
var job2 = launch {
delay(1000)
Log.d("lyy","---job2----:${getCurrentTime()}")
}
}
//结果:
lyy: ---job1----:2026年01月23日 17:30:36
lyy: ---job2----:2026年01月23日 17:30:37
3、协程中 job2先执后执行完行完,job1;
lifecycleScope.launch {
var job1 = launch {
delay(3000)
Log.d("lyy","---job1----:${getCurrentTime()}")
}
var job2 = launch {
delay(1000)
Log.d("lyy","---job2----:${getCurrentTime()}")
}
}
//结果:
lyy: ---job2----:2026年01月23日 17:28:14
lyy: ---job1----:2026年01月23日 17:28:16
4、job1的join(),不影响协程外部的代码执行:
lifecycleScope.launch {
var job1 = launch {
delay(1000)
Log.d("lyy","---job1----:${getCurrentTime()}")
}
job1.join()
var job2 = launch {
delay(1000)
Log.d("lyy","---job2----:${getCurrentTime()}")
}
}
//协程外部:
Log.d("lyy","---job3----:")
//结果:
lyy: ---job3----:2026年01月23日 17:38:27
lyy: ---job1----:2026年01月23日 17:38:28
lyy: ---job2----:2026年01月23日 17:38:29
5、先等job1执行完,再执行job2;
lifecycleScope.launch {
var job1 = launch {
delay(1000)
Log.d("lyy","---job1----:${getCurrentTime()}")
}
job1.join()
var job2 = launch {
delay(1000)
Log.d("lyy","---job2----:${getCurrentTime()}")
}
job2.join()
}
//结果:
lyy: ---job1----:2026年01月23日 17:42:25
lyy: ---job2----:2026年01月23日 17:42:26
6、报错,调用的时候必须是挂起函数
var job3 = CoroutineScope(Dispatchers.Main).launch {
delay(3000)
Log.d("lyy","---job3----:${getCurrentTime()}")
}
//报错,调用的时候必须是挂起函数
//job3.join()
10、supervisorScope或SupervisorJob 怎么使用
-
SupervisorJob
- 一种特殊的
Job,其特点是子协程的异常不会向上传播,也不会取消父协程或其他兄弟协程。 - 适用于需要独立处理错误的场景(如并行加载多个独立资源)。
- 一种特殊的
-
supervisorScope
- 基于
SupervisorJob的作用域构建器,自动创建独立的作用域,管理子协程的生命周期和异常传播。
- 基于
1. 直接使用 SupervisorJob
import kotlinx.coroutines.*
fun main() = runBlocking {
val supervisor = SupervisorJob() // 创建 SupervisorJob 实例
val scope = CoroutineScope(coroutineContext + supervisor) // 绑定到当前上下文
scope.launch {
// 子协程1:抛出异常但不影响其他协程
throw RuntimeException("Task 1 failed")
}
scope.launch {
// 子协程2:正常执行
delay(1000)
println("Task 2 completed")
}
delay(2000)
supervisor.cancel() // 手动取消整个作用域
}
//Task 2 completed
说明:子协程1的异常被隔离,子协程2正常执行。
2. 使用 supervisorScope构建器
suspend fun fetchData() = supervisorScope {
val task1 = async {
// 模拟失败
throw IOException("Network error")
}
val task2 = async {
delay(500)
"Data loaded"
}
try {
println(task1.await()) // 捕获异常
} catch (e: IOException) {
println("Handled error: ${e.message}")
}
println(task2.await()) // 正常执行
}
fun main() = runBlocking {
fetchData()
}
//Handled error: Network error
//Data loaded
说明:supervisorScope内部的异常不会传播到外部,需手动处理。
关键特性对比
| 特性 | SupervisorJob | supervisorScope |
|---|---|---|
| 创建方式 | 显式实例化 SupervisorJob() | 通过 supervisorScope { ... }构建 |
| 异常传播 | 子协程异常不向上传播 | 子协程异常不向上传播 |
| 生命周期管理 | 需手动管理 | 自动绑定到作用域生命周期 |
| 适用场景 | 需要动态控制多个独立任务 | 一次性定义独立任务组 |
11、MMKV可以保存大量数据?
MMKV可以保存一定量的数据,但绝对不推荐用于存储“海量”数据。 MMKV的极致性能源于其独特的设计,而恰恰是这些设计,在面对海量数据时成为了瓶颈:
| 限制维度 | 具体原因分析 |
|---|---|
| 技术设计 | 采用内存映射文件,读写都在内存中完成。海量数据会占用巨大内存,易引发OOM(尤其是在32位设备或内存紧张时)。官方文档也明确指出“不适用于超大文件存储”。 |
| 写入模式 | 追加写模式(修改数据不删除旧值,只在文件末尾追加新值)导致文件会持续增大。虽然定期“重整”,但数据量越大,重整操作越耗时、越频繁,可能造成卡顿。 |
| 检索效率 | 虽然内存索引快,但本质上仍是Key-Value模型,不支持任何关系查询、排序、聚合。要遍历或筛选数据,必须在代码中加载全部数据,这在数据量大时是不可行的。 |
简单来说,使用MMKV存海量数据,就像用超级跑车去拉货跑长途。它短途(少量数据)极快,但货箱小(内存限制)、油耗高(空间浪费)、不适合复杂路况(复杂查询),最终会因维护成本过高而变得异常低效。
12、 如何判断数据量是否“过大”?
虽然没有绝对标准,但可以参考以下经验法则:
- 安全范围:存储几百到几千条简单的键值对数据(如用户配置、开关状态、缓存Token等)是MMKV最理想、最擅长的场景。
- 预警线:当预估数据总量达到10MB级别或条目过万时,就应开始慎重考虑。
- 绝对红线:不要尝试用它存储聊天记录、业务日志、用户内容列表等潜在增长巨大的数据集。
13、 MMKV 数据存储到哪里了?是存到Java的方法区还是堆里面?
这是一个非常好的底层问题。答案是:都不是。MMKV的数据既不存储在Java堆里,也不在方法区,而是通过
mmap(内存映射文件) 技术,存储在由操作系统内核管理的一块“映射内存”中,这块内存在物理上通常对应着磁盘上的文件。
-
mmap是什么?
简单来说,它让一个磁盘文件和一个内存地址空间直接关联。你对这块内存的读写操作,会由操作系统自动、异步地同步到对应的文件里。MMKV正是利用了这一点。 -
MMKV的数据在哪?
- 持久化位置:数据最终存储在应用沙盒内的一个物理文件里(如
/data/data/包名/files/mmkv/mmkv.default)。 - 运行时位置:当MMKV初始化时,它通过
mmap将这个文件映射到进程的虚拟内存地址空间中。你通过encode方法写入的字符串、整数等数据,会先被protobuf编码成二进制,然后直接写入这块映射内存。因此,数据主体位于图中所示的 “内存映射区域” 。
- 持久化位置:数据最终存储在应用沙盒内的一个物理文件里(如
14、Kotlin:内置函数let、also、with、run、apply 总结他们的区别和使用场景?
区别集中在 “如何引用上下文对象” 和 “返回值是什么” 这两点上。
这张表格汇总了它们最本质的差异,是理解所有例子的基础:
| 函数 | 上下文对象引用方式 (代码块内) | 返回值 | 典型使用场景(一句话概括) |
|---|---|---|---|
let | it (默认,可更名) | Lambda表达式结果 | 空安全调用、对象转换、引入局部作用域。 |
also | it (默认,可更名) | 对象本身 | 执行副作用操作(如打印日志、数据验证),不影响对象本身。 |
run | this (可省略) | Lambda表达式结果 | 对一个对象执行计算并返回结果,或在需要计算表达式时作为代码块。 |
apply | this (可省略) | 对象本身 | 对象的初始化与配置(链式调用)。 |
with | this (可省略) | Lambda表达式结果 | 对同一个对象进行多个操作,无需返回对象本身(非扩展函数)。 |
结与最佳实践
- 空安全处理用
let:处理可空对象时最安全、最清晰。 - 额外操作和验证用
also:在操作流程中插入日志、验证等副作用。 - 初始化配置用
apply:创建对象并立即配置其属性。 - 计算并返回结果用
run:需要利用对象属性计算新值时使用。 - 集中操作非空对象用
with:对已知非空对象进行一系列操作。
记忆口诀:
- “让它(let)转换,也(also)记录,运行(run)计算,应用(apply)配置,与(with)共事。”
- 返回自身是
also和apply(想记“AA”原则:Also Apply 都返回自己)。 - 使用
it的是also和let。
let:安全的转换器
核心:处理可空对象,或将对象转换为其他类型。
// 场景1: 空安全调用 (最常见)
user?.let { // 仅当user非空时执行
updateUI(it.name, it.age) // `it` 指代非空的user
}
// 场景2: 转换对象
val numbers = listOf(1, 2, 3)
val doubled = numbers.let { it.map { num -> num * 2 } }
// 场景3: 引入清晰的作用域变量
message.let { msg -> // 可重命名,避免命名冲突
println("$msg World")
}
2. also:细心的记录员
核心:做额外的事情,不影响对象本身。
// 场景: 在执行链中进行日志、验证等
val file = File("path")
.also { println("创建文件: ${it.name}") } // 打印日志
.apply { writeText("content") } // 配置内容
.also { println("文件大小: ${it.length()}") } // 再次记录
3. run:多面的执行者
核心:全能选手,适合计算和需要返回结果的场景。
// 场景1: 对某个对象进行一系列操作并返回结果
val result = userProfile.run {
val description = "$firstName ($age岁)"
calculateScore(description) // 最后一行作为返回值
}
// 场景2: 作为代码块,执行一系列语句
val hasNetwork = run {
val conn = checkNetworkConnection()
conn != null && conn.isActive
}
apply:优秀的配置员
核心:专门用于初始化配置,返回对象本身,便于链式调用。
// 场景: 初始化对象 (Android中极常见)
val textView = TextView(context).apply {
text = "Hello"
textSize = 18f
setPadding(10, 5, 10, 5)
// 没有显式返回,但返回的是this (即TextView对象)
}
// 之后可以直接使用textView
parentView.addView(textView)
5. with:专注的操作员
核心:对一个对象集中操作,无需返回对象本身。
// 场景: 对一个对象调用多个方法
val builder = StringBuilder()
with(builder) {
append("Hello")
append(" ")
append("World")
// 无需返回builder,但我们仍在操作它
}
println(builder.toString())
14、Android中使用什么垃圾回收器?
单来说,Android并未使用标准JVM的垃圾回收器。它的垃圾回收器是内置在运行时 (Runtime) 中的核心组件,随Android版本进化,尤其在Android 8.0后主要使用并发压缩式垃圾回收器,与标准的“Serial GC”等有本质区别
以下是Android垃圾回收器的演进历程与核心特点,方便你快速把握:
15、 并发压缩式GC如何工作?
这种回收器能在大多数时间与应用线程并发工作,主要通过以下步骤:
- 短暂暂停:短暂挂起所有应用线程,以快速扫描线程栈等“GC根”(Root)对象。
- 并发标记:恢复应用线程,同时GC线程并发地遍历对象图,标记存活对象。
- 并发/部分暂停重标记:处理并发标记期间对象图的变化。
- 并发压缩/清理:并发地将存活对象移动到内存一端以压缩堆,回收碎片空间,为新对象分配提供连续内存,这也是其分配速度比Android 7.0快70%的原因之一 。
它的主要优势是GC停顿时间大幅减少且可预测,不再随堆大小显著增加,有效改善了应用卡顿。