2026年面试4

35 阅读18分钟

1、mvc、mvp和mvvm的区别,总结一下

  1. 从MVC到MVP:解决“上帝Activity”和“可测试性”

    • MVC问题:在Android中,Activity/Fragment 通常身兼ViewController,导致它们过于庞大(“上帝对象”),且包含大量Android依赖,几乎无法进行单元测试
    • MVP改进:通过引入Presenter,将所有的展示逻辑View中剥离。Presenter是纯POJO,可以轻松进行单元测试。View变得被动,只负责展示和转发事件。
  2. 从MVP到MVVM:解决“接口爆炸”和“手动同步”

    • MVP问题:每个View都需要定义详细的IView接口,导致接口数量庞大。同时,Presenter需要手动调用view.showXxx()来更新UI,繁琐且易漏。
    • MVVM改进:引入ViewModel数据绑定(或响应式流)。ViewModel暴露可观察的数据状态(如LiveData<UiState>),View(通过绑定或观察者)自动订阅这些状态并更新。省去了大量接口和手动更新UI的代码,数据驱动UI的理念更清晰。

2、MMKV中 protobuf的面试题

在MMKV中,Protocol Buffers (Protobuf) 的应用是其性能远超 SharedPreferences 的关键。核心在于其高效的序列化、紧凑的存储格式以及与MMKV追加写入模式的深度适配

下表总结了MMKV中Protobuf实现的核心要点,你可以快速掌握整体概念:

8e3289a861357120e95e128b0eea192c.png

820a589f3328f6d7dd35e901a2c6642f.png

3、 Protobuf深入技术细节

  1. 变长编码的实现
    Protobuf的Varint编码,每个字节的最高位是标志位,表示后续是否还有字节。例如,对于小整数300(二进制1 0010 1100),编码后只需2个字节1010 1100 0000 0010,而非4个字节
  • MMBuffer设计
    MMKV内部使用MMBuffer封装数据。对于编码后小于等于10字节的小数据(如整型),直接存储在栈上的paddedBuffer数组中,避免堆内存分配;大数据则存储在堆中。这进一步优化了频繁写入小数据的性能。

  • 增量更新流程
    当调用mmkv.encode(“key”, value)时,其流程为:

    • 将Key和Value分别进行Protobuf序列化。
    • 在内存映射区域末尾,顺序写入:[Key的长度(Varint)][Key][Value的长度(Varint)][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 { }
返回类型JobDeferred<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 中使用的关键注意事项

    1. 生命周期自动取消:无论 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()

总结与最佳实践

  1. 默认不调用:作为基本规则,当你使用 lifecycleScope.launch 启动一个协程后,默认不要调用 join() 。让它异步运行。
  2. 理解 join() 的作用:它用于协程内部的同步控制,而不是在普通代码流中调用。
  3. 利用结构化并发:如果任务B必须等待任务A,更清晰的写法是直接将任务B的代码放在任务A的协程体后面,或者使用 async/await 来获取结果并控制流程。
  4. 不要阻塞UI线程:永远记住,在主线程直接调用 join()runBlocking 是危险的。

9、协程的执行顺序;

在 Kotlin 协程中,lifecycleScope.launch启动的协程默认是 并发执行​ 的。

并发执行指的是系统在宏观上看起来能同时处理多个任务的能力。它不是指微观上“同一时刻”执行多个任务(那是“并行”),而是指通过快速切换和调度,让多个任务在一段时间内都得到推进,从而给人以“同时发生”的错觉。

e0c3862d35d32dfaa8c3e242ba43a79d.png

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----:2026012317:16:51
lyy: ---job2----:2026012317: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----:2026012317:30:36
lyy: ---job2----:2026012317: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----:2026012317:28:14
lyy: ---job1----:2026012317: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----:2026012317:38:27
lyy: ---job1----:2026012317:38:28
lyy: ---job2----:2026012317: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----:2026012317:42:25
lyy: ---job2----:2026012317: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内部的异常不会传播到外部,需手动处理。

关键特性对比

特性SupervisorJobsupervisorScope
创建方式显式实例化 SupervisorJob()通过 supervisorScope { ... }构建
异常传播子协程异常不向上传播子协程异常不向上传播
生命周期管理需手动管理自动绑定到作用域生命周期
适用场景需要动态控制多个独立任务一次性定义独立任务组

11、MMKV可以保存大量数据?

MMKV可以保存一定量的数据,但绝对不推荐用于存储“海量”数据。 MMKV的极致性能源于其独特的设计,而恰恰是这些设计,在面对海量数据时成为了瓶颈:

限制维度具体原因分析
技术设计采用内存映射文件,读写都在内存中完成。海量数据会占用巨大内存,易引发OOM(尤其是在32位设备或内存紧张时)。官方文档也明确指出“不适用于超大文件存储”。
写入模式追加写模式(修改数据不删除旧值,只在文件末尾追加新值)导致文件会持续增大。虽然定期“重整”,但数据量越大,重整操作越耗时、越频繁,可能造成卡顿。
检索效率虽然内存索引快,但本质上仍是Key-Value模型,不支持任何关系查询、排序、聚合。要遍历或筛选数据,必须在代码中加载全部数据,这在数据量大时是不可行的。

简单来说,使用MMKV存海量数据,就像用超级跑车去拉货跑长途。它短途(少量数据)极快,但货箱小(内存限制)、油耗高(空间浪费)、不适合复杂路况(复杂查询),最终会因维护成本过高而变得异常低效。

12、 如何判断数据量是否“过大”?

虽然没有绝对标准,但可以参考以下经验法则:

  • 安全范围:存储几百到几千条简单的键值对数据(如用户配置、开关状态、缓存Token等)是MMKV最理想、最擅长的场景。
  • 预警线:当预估数据总量达到10MB级别或条目过万时,就应开始慎重考虑。
  • 绝对红线不要尝试用它存储聊天记录、业务日志、用户内容列表等潜在增长巨大的数据集。

13、 MMKV 数据存储到哪里了?是存到Java的方法区还是堆里面?

这是一个非常好的底层问题。答案是:都不是。MMKV的数据既不存储在Java堆里,也不在方法区,而是通过 mmap(内存映射文件) 技术,存储在由操作系统内核管理的一块“映射内存”中,这块内存在物理上通常对应着磁盘上的文件。

  1. mmap是什么?
    简单来说,它让一个磁盘文件和一个内存地址空间直接关联。你对这块内存的读写操作,会由操作系统自动、异步地同步到对应的文件里。MMKV正是利用了这一点。

  2. MMKV的数据在哪?

    • 持久化位置:数据最终存储在应用沙盒内的一个物理文件里(如 /data/data/包名/files/mmkv/mmkv.default)。
    • 运行时位置:当MMKV初始化时,它通过 mmap 将这个文件映射到进程的虚拟内存地址空间中。你通过 encode 方法写入的字符串、整数等数据,会先被protobuf编码成二进制,然后直接写入这块映射内存。因此,数据主体位于图中所示的 “内存映射区域”

14、Kotlin:内置函数let、also、with、run、apply 总结他们的区别和使用场景?

区别集中在 “如何引用上下文对象”“返回值是什么” 这两点上。

这张表格汇总了它们最本质的差异,是理解所有例子的基础:

函数上下文对象引用方式 (代码块内)返回值典型使用场景(一句话概括)
letit (默认,可更名)Lambda表达式结果空安全调用对象转换引入局部作用域
alsoit (默认,可更名)对象本身执行副作用操作(如打印日志、数据验证),不影响对象本身。
runthis (可省略)Lambda表达式结果对一个对象执行计算并返回结果,或在需要计算表达式时作为代码块。
applythis (可省略)对象本身对象的初始化与配置(链式调用)。
withthis (可省略)Lambda表达式结果对同一个对象进行多个操作,无需返回对象本身(非扩展函数)。

结与最佳实践

  • 空安全处理用 let:处理可空对象时最安全、最清晰。
  • 额外操作和验证用 also:在操作流程中插入日志、验证等副作用。
  • 初始化配置用 apply:创建对象并立即配置其属性。
  • 计算并返回结果用 run:需要利用对象属性计算新值时使用。
  • 集中操作非空对象用 with:对已知非空对象进行一系列操作。

记忆口诀

  • “让它(let)转换,也(also)记录,运行(run)计算,应用(apply)配置,与(with)共事。”
  • 返回自身是 alsoapply (想记“AA”原则:Also Apply 都返回自己)。
  • 使用 it 的是 alsolet
  1. 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
}

  1. 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垃圾回收器的演进历程与核心特点,方便你快速把握:

Android 版本/运行时主要垃圾回收器核心特点与演进
早期版本 (Dalvik)非分代的标记-清除 (Mark-Sweep)基于标记-清除算法,会导致内存碎片,GC时停止所有线程 (Stop-The-World) ,卡顿明显。
Android 5.0 - 7.x (ART)分代式垃圾回收 (Generational GC)引入分代概念,针对年轻代和老年代使用不同策略(如复制、标记-清除-整理),显著提升效率。但主要阶段仍需暂停应用线程
Android 8.0+ (ART)并发压缩式垃圾回收器 (Concurrent Compacting GC)里程碑式更新。核心改进: 1. 并发:大部分GC工作与应用线程并发执行,仅短暂停顿。 2. 压缩:每次GC都压缩堆以消除碎片,平均堆大小比7.0小32%[]
后续版本在并发压缩式GC基础上持续优化如调整策略、改进“读取屏障”性能等,但核心架构未变

15、 并发压缩式GC如何工作?

这种回收器能在大多数时间与应用线程并发工作,主要通过以下步骤:

  1. 短暂暂停:短暂挂起所有应用线程,以快速扫描线程栈等“GC根”(Root)对象。
  2. 并发标记:恢复应用线程,同时GC线程并发地遍历对象图,标记存活对象。
  3. 并发/部分暂停重标记:处理并发标记期间对象图的变化。
  4. 并发压缩/清理并发地将存活对象移动到内存一端以压缩堆,回收碎片空间,为新对象分配提供连续内存,这也是其分配速度比Android 7.0快70%的原因之一 。

它的主要优势是GC停顿时间大幅减少且可预测,不再随堆大小显著增加,有效改善了应用卡顿。