本文以设计一个新闻客户端为例,展示如何用 Jetpack 全套组件从 0 到 1 搭建符合 Google 官方推荐的 MVVM 应用架构。
参考:Android Architecture Guide
一、架构层次总览
┌──────────────────────────────────────────────┐ │ UI Layer (Fragment/Activity + ViewModel) │ │ ┌─────────────┐ ┌──────────────────────┐ │ │ │ DataBinding │ │ Navigation Compose │ │ │ └─────────────┘ └──────────────────────┘ │ ├──────────────────────────────────────────────┤ │ Domain / Repository Layer │ │ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ │ │ Room │ │ Retrofit │ │ WorkManager │ │ │ └──────────┘ └──────────┘ └──────────────┘ │ ├──────────────────────────────────────────────┤ │ Data Layer │ │ ┌──────────────┐ ┌──────────────────────┐ │ │ │ Local Source │ │ Remote Source │ │ │ │ (Room/Raw) │ │ (Retrofit/OkHttp) │ │ │ └──────────────┘ └──────────────────────┘ │ └──────────────────────────────────────────────┘
|
二、Repository + Room + Retrofit + Paging
离线优先的核心:Repository 作为单一入口,优先取本地数据,后台从网络拉取并缓存。
class NewsRepository @Inject constructor( private val newsDao: NewsDao, private val api: NewsApiService ) { fun getNewsStream(): Flow<PagingData<News>> = Pager( config = PagingConfig(pageSize = 20), remoteMediator = NewsRemoteMediator(newsDao, api) ) { newsDao.pagingSource() }.flow.cachedIn(CoroutineScope(Dispatchers.IO)) }
|
三、依赖注入:Hilt
@Module @InstallIn(SingletonComponent::class) object DataModule { @Provides @Singleton fun provideNewsDao(db: AppDatabase): NewsDao = db.newsDao()
@Provides @Singleton fun provideApi(): NewsApiService = Retrofit.Builder() .baseUrl(BASE_URL) .addConverterFactory(MoshiConverterFactory.create()) .build() .create(NewsApiService::class.java) }
|
Hilt 通过 @AndroidEntryPoint 注入 Activity/Fragment,通过 @HiltViewModel 注入 ViewModel 的 Repository 依赖。
四、Single Activity + Navigation
class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
val navController = findNavController(R.id.nav_host_fragment) binding.bottomNav.setupWithNavController(navController)
setupActionBarWithNavController(navController) } }
|
五、测试策略
- ViewModel 单元测试:注入 Fake Repository,测试数据转换逻辑。
- Repository 集成测试:使用 Room 内存数据库 + MockWebServer 测试 RemoteMediator 分页逻辑。
- UI 测试:使用 Espresso + Navigation 测试导航流程。
六、整体依赖关系
Activity (DI entry) └── NavHostFragment ├── HomeFragment → HomeViewModel → NewsRepository │ ├── Room (NewsDao) │ └── Retrofit (NewsApi) └── DetailFragment → DetailViewModel (by viewModels)
|
面试常考问题
Q1:为什么推荐 Single Activity 架构?
减少 Activity 跨进程通信(Binder)开销,Navigation 组件接管 Fragment 跳转栈,避免多 Activity 的场景(如多窗口、生命周期不一致)带来的复杂性。而且在 Jetpack Compose 时代,Single Activity 是唯一正确的选择。
Q2:Repository 是否必须?能否让 ViewModel 直接调用 ApiService?
Repository 是推荐模式,但不强制。它提供了统一的接口,让 ViewModel 不关心数据来源(本地或远程),同时便于替换数据源(如单元测试时注入 Fake Repository)。直接调用 ApiService 会使 ViewModel 职责过重,违反单一职责原则。
Q3:如何避免 Paging 3 的 RemoteMediator 重复插入数据?
在数据库 DAO 中使用 @Insert(onConflict = OnConflictStrategy.REPLACE) 或 @Upsert,以唯一键处理重复。同时在 REFRESH 时调用 clearAll() + insertAll() 覆盖数据。RemoteMediator 内部自动处理并发加载,但需确保数据源设计合理。