一、简介

本文将带你从零实现一个简单但架构规范的 Android 应用:
新闻列表 App,功能包括:

  • 首次启动从网络拉取新闻列表并展示;
  • 数据会缓存到本地数据库(Room);
  • 下次进入应用时即使没有网络,也能看到上一次加载的内容(离线可用);
  • 使用 MVVM + Repository + Room + Retrofit + Kotlin 协程实现。

重点不在 UI 炫酷程度,而在于:

  • 架构是否清晰、可维护;
  • 代码是否真实可用、逻辑是否正确;
  • 是否符合当前主流 Android 开发实践。

二、相关概念简述

1. MVVM(模型-视图-视图模型)

  • Model:数据层(网络、数据库等),如 Retrofit、Room。
  • View:界面层(Activity/Fragment/自定义 View)。
  • ViewModel:负责业务逻辑,持有 UI 状态,不直接引用 View,和生命周期解耦。

优点:
逻辑清晰、可测试性强,减少 Activity/Fragment 的复杂性。


2. 仓库(仓库模式)

Repository 作为唯一的数据入口,负责:

  • 决定从网络还是从本地取数据;
  • 负责数据合并、缓存策略(例如先本地后网络刷新)。

优点:
上层(ViewModel)不关心数据来源,降低耦合度。


3. Room(本地数据库)

  • Google 官方推荐的 SQLite 封装库;
  • 自动生成 SQLite 代码;
  • 支持 Kotlin 协程 / Flow;
  • 编译期检查 SQL 语句是否正确。

4. Retrofit(网络请求)

  • 主流的 HTTP 客户端库;
  • 配合协程可直接使用 函数发起网络请求;suspend
  • 配合 Gson/Moshi 自动 JSON 解析。

5. Kotlin 协程 + Flow

  • 协程:轻量级线程,用于异步、并发;
  • Flow:可被收集的“数据流”,适合表示随时间推移变化的数据(如数据库数据变更)。

在本项目中:

  • Retrofit 的网络请求使用 函数;suspend
  • Room 的查询返回 ;Flow<List<Article>>
  • ViewModel 收集 Flow,转为 LiveData 暴露给 UI。

6. 单一数据源(单一数据源)

核心思想:

UI 只观察一个“真相来源”(本地数据库),网络数据只是用来更新本地数据库

本例中:

  • 网络 -> Repository -> 写入 Room;
  • UI -> 只观察 Room 中的数据。

好处:
数据一致、状态清晰、避免多处维护一份数据。

三、项目结构设计

假设包名为 ,推荐目录结构如下:com.example.newsapp

com.example.newsapp
├── data
│   ├── local
│   │   ├── AppDatabase.kt
│   │   └── ArticleDao.kt
│   ├── remote
│   │   ├── ApiService.kt
│   │   └── NetworkModule.kt (可选,集中管理 Retrofit)
│   ├── model
│   │   └── Article.kt
│   └── repository
│       └── ArticleRepository.kt
├── ui
│   ├── main
│   │   ├── MainActivity.kt
│   │   ├── ArticleAdapter.kt
│   │   └── ArticleViewModel.kt
│   └── common
│       └── UiState.kt
└── App.kt (Application)

四、实现步骤

步骤 1:创建项目与依赖配置

1.1 创建空项目
  • 在 Android Studio 中创建 Empty Activity Kotlin 项目;
  • 最低 SDK 可选 21+。
1.2 Gradle 依赖(module 级 )build.gradle

仅示意常用依赖,版本号请根据实际最新稳定版调整:

plugins {
    id 'com.android.application'
    id 'org.jetbrains.kotlin.android'
    id 'kotlin-kapt'
}

android {
    namespace 'com.example.newsapp'
    compileSdk 34

    defaultConfig {
        applicationId "com.example.newsapp"
        minSdk 21
        targetSdk 34
        versionCode 1
        versionName "1.0"
    }

    buildFeatures {
        viewBinding true
    }

    kotlinOptions {
        jvmTarget = '1.8'
    }
}

dependencies {
    // Kotlin 协程
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1"
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1"

    // Retrofit + Gson
    implementation "com.squareup.retrofit2:retrofit:2.11.0"
    implementation "com.squareup.retrofit2:converter-gson:2.11.0"
    implementation "com.squareup.okhttp3:logging-interceptor:5.0.0-alpha.14"

    // Room
    implementation "androidx.room:room-runtime:2.6.1"
    kapt "androidx.room:room-compiler:2.6.1"
    implementation "androidx.room:room-ktx:2.6.1"

    // Lifecycle (ViewModel + LiveData + 扩展)
    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7"
    implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.8.7"

    // RecyclerView
    implementation "androidx.recyclerview:recyclerview:1.3.2"

    // Material Design
    implementation "com.google.android.material:material:1.12.0"

    // AppCompat
    implementation "androidx.appcompat:appcompat:1.7.0"

    // Core KTX
    implementation "androidx.core:core-ktx:1.13.1"
}

步骤 2:数据模型定义(Article)

data/model/Article.kt

package com.example.newsapp.data.model

import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "articles")
data class Article(
    @PrimaryKey(autoGenerate = true)
    val id: Long = 0L,

    val title: String,
    val description: String?,
    val url: String?,
    val imageUrl: String?,
    val publishedAt: Long // 时间戳(毫秒)
)

说明:

  • 使用 标记为 Room 表;@Entity
  • id自增主键;
  • 其他字段根据简单新闻结构设计。

步骤 3:本地数据库 Room

3.1 Dao 接口

data/local/ArticleDao.kt

package com.example.newsapp.data.local

import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.example.newsapp.data.model.Article
import kotlinx.coroutines.flow.Flow

@Dao
interface ArticleDao {

    // 观察所有文章(按时间倒序)
    @Query("SELECT * FROM articles ORDER BY publishedAt DESC")
    fun getAllArticles(): Flow<List<Article>>

    // 清空表
    @Query("DELETE FROM articles")
    suspend fun clearAll()

    // 批量插入(冲突时替换)
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertArticles(articles: List<Article>)
}
3.2 数据库类

data/local/AppDatabase.kt

package com.example.newsapp.data.local

import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import com.example.newsapp.data.model.Article

@Database(
    entities = [Article::class],
    version = 1,
    exportSchema = false
)
abstract class AppDatabase : RoomDatabase() {

    abstract fun articleDao(): ArticleDao

    companion object {
        @Volatile
        private var INSTANCE: AppDatabase? = null

        fun getInstance(context: Context): AppDatabase {
            return INSTANCE ?: synchronized(this) {
                INSTANCE ?: Room.databaseBuilder(
                    context.applicationContext,
                    AppDatabase::class.java,
                    "news_db"
                ).build().also { INSTANCE = it }
            }
        }
    }
}

步骤 4:网络层 Retrofit

为保证示例完整性,这里假设有一个简单的 JSON 接口:

data/repository/ArticleRepository.kt

ui/main/ArticleViewModel.kt

  • 网址:https://example.com/api/news
  • 返回结构示例:
    {
      "articles": [
        {
          "title": "Title 1",
          "description": "Desc 1",
          "url": "https://example.com/1",
          "imageUrl": "https://example.com/img1.jpg",
          "publishedAt": 1712400000000
        }
      ]
    }

    实际项目中请替换为你自己的后端接口。

    4.1 网络响应数据类

    data/remote/NewsResponse.kt

    package com.example.newsapp.data.remote
    
    import com.example.newsapp.data.model.Article
    
    data class NewsResponse(
        val articles: List<Article>
    )

  • 这里直接复用 作为网络模型和数据库模型,真实项目中通常会拆成 与 ,再做映射。ArticleApiArticleDbArticle

    4.2 Retrofit 接口

    data/remote/ApiService.kt

    package com.example.newsapp.data.remote
    
    import retrofit2.http.GET
    
    interface ApiService {
    
        @GET("api/news")
        suspend fun getNews(): NewsResponse
    }
    4.3 改造单例

    data/remote/NetworkModule.kt

    package com.example.newsapp.data.remote
    
    import okhttp3.OkHttpClient
    import okhttp3.logging.HttpLoggingInterceptor
    import retrofit2.Retrofit
    import retrofit2.converter.gson.GsonConverterFactory
    
    object NetworkModule {
    
        private const val BASE_URL = "https://example.com/" // TODO: 替换为真实地址
    
        private val loggingInterceptor = HttpLoggingInterceptor().apply {
            level = HttpLoggingInterceptor.Level.BODY
        }
    
        private val okHttpClient = OkHttpClient.Builder()
            .addInterceptor(loggingInterceptor)
            .build()
    
        val apiService: ApiService by lazy {
            Retrofit.Builder()
                .baseUrl(BASE_URL)
                .client(okHttpClient)
                .addConverterFactory(GsonConverterFactory.create())
                .build()
                .create(ApiService::class.java)
        }
    }

    步骤 5:Repository(数据仓库)

    负责:

  • 对外暴露 Flow<List

    >(来自 Room);

  • 提供刷新函数,从网络取数据并写入 Room;
  • 处理异常,返回结果标记刷新是否成功。
    package com.example.newsapp.data.repository
    
    import com.example.newsapp.data.local.ArticleDao
    import com.example.newsapp.data.model.Article
    import com.example.newsapp.data.remote.NetworkModule
    import kotlinx.coroutines.flow.Flow
    
    class ArticleRepository(
        private val articleDao: ArticleDao
    ) {
    
        // 对外暴露本地数据库的 Flow(Single Source of Truth)
        fun getArticles(): Flow<List<Article>> = articleDao.getAllArticles()
    
        // 从网络刷新数据并存入本地
        suspend fun refreshArticles(): Result<Unit> {
            return try {
                val response = NetworkModule.apiService.getNews()
                // 简单策略:清空再插入
                articleDao.clearAll()
                articleDao.insertArticles(response.articles)
                Result.success(Unit)
            } catch (e: Exception) {
                Result.failure(e)
            }
        }
    }

    步骤 6:UI 状态封装(可选但推荐)

    用于表示加载中、成功、失败等状态。

    ui/common/UiState.kt

    package com.example.newsapp.ui.common
    
    sealed class UiState<out T> {
        object Loading : UiState<Nothing>()
        data class Success<T>(val data: T) : UiState<T>()
        data class Error(val throwable: Throwable) : UiState<Nothing>()
    }

    步骤 7:视图模型

  • 暴露 ;LiveData<UiState<List<Article>>>
  • 负责触发刷新;
  • 在初始化时自动刷新一次。
    package com.example.newsapp.ui.main
    
    import androidx.lifecycle.*
    import com.example.newsapp.data.model.Article
    import com.example.newsapp.data.repository.ArticleRepository
    import com.example.newsapp.ui.common.UiState
    import kotlinx.coroutines.flow.map
    import kotlinx.coroutines.launch
    
    class ArticleViewModel(
        private val repository: ArticleRepository
    ) : ViewModel() {
    
        // 来自本地数据库的 Flow<List<Article>>
        private val articlesFlow = repository.getArticles()
    
        // 将 Flow 转为 LiveData,同时包上 UiState
        val articlesLiveData: LiveData<UiState<List<Article>>> =
            articlesFlow
                .map< List<Article>, UiState<List<Article>> > { list ->
                    UiState.Success(list)
                }
                .asLiveData()
    
        private val _refreshState = MutableLiveData<UiState<Unit>>()
        val refreshState: LiveData<UiState<Unit>> = _refreshState
    
        init {
            refresh()
        }
    
        fun refresh() {
            viewModelScope.launch {
                _refreshState.value = UiState.Loading
                val result = repository.refreshArticles()
                _refreshState.value = result.fold(
                    onSuccess = { UiState.Success(Unit) },
                    onFailure = { UiState.Error(it) }
                )
            }
        }
    }
    
    /**
     * 简单的 ViewModelFactory,生产 ArticleViewModel
     */
    class ArticleViewModelFactory(
        private val repository: ArticleRepository
    ) : ViewModelProvider.Factory {
    
        @Suppress("UNCHECKED_CAST")
        override fun <T : ViewModel> create(modelClass: Class<T>): T {
            if (modelClass.isAssignableFrom(ArticleViewModel::class.java)) {
                return ArticleViewModel(repository) as T
            }
            throw IllegalArgumentException("Unknown ViewModel class")
        }
    }

    步骤 8:RecyclerView 适配器与布局

    8.1 item 布局

    res/layout/item_article.xml

    <?xml version="1.0" encoding="utf-8"?>
    <androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:card_view="http://schemas.android.com/apk/res-auto"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_margin="8dp"
        card_view:cardUseCompatPadding="true">
    
        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="vertical"
            android:padding="12dp">
    
            <TextView
                android:id="@+id/tvTitle"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:textSize="16sp"
                android:textStyle="bold"
                android:textColor="@android:color/black" />
    
            <TextView
                android:id="@+id/tvDescription"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_marginTop="4dp"
                android:textSize="14sp"
                android:textColor="@android:color/darker_gray" />
    
            <TextView
                android:id="@+id/tvPublishedAt"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_marginTop="4dp"
                android:textSize="12sp"
                android:textColor="@android:color/darker_gray" />
        </LinearLayout>
    
    </androidx.cardview.widget.CardView>
    8.2 适配器

    ui/main/ArticleAdapter.kt

    package com.example.newsapp.ui.main
    
    import android.view.LayoutInflater
    import android.view.ViewGroup
    import androidx.recyclerview.widget.RecyclerView
    import com.example.newsapp.data.model.Article
    import com.example.newsapp.databinding.ItemArticleBinding
    import java.text.SimpleDateFormat
    import java.util.*
    
    class ArticleAdapter(
        private val onItemClick: (Article) -> Unit
    ) : RecyclerView.Adapter<ArticleAdapter.ArticleViewHolder>() {
    
        private val list = mutableListOf<Article>()
        private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault())
    
        fun submitList(newList: List<Article>) {
            list.clear()
            list.addAll(newList)
            notifyDataSetChanged()
        }
    
        inner class ArticleViewHolder(
            private val binding: ItemArticleBinding
        ) : RecyclerView.ViewHolder(binding.root) {
    
            fun bind(item: Article) {
                binding.tvTitle.text = item.title
                binding.tvDescription.text = item.description ?: ""
                binding.tvPublishedAt.text = dateFormat.format(Date(item.publishedAt))
    
                binding.root.setOnClickListener {
                    onItemClick(item)
                }
            }
        }
    
        override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ArticleViewHolder {
            val binding = ItemArticleBinding.inflate(
                LayoutInflater.from(parent.context),
                parent,
                false
            )
            return ArticleViewHolder(binding)
        }
    
        override fun onBindViewHolder(holder: ArticleViewHolder, position: Int) {
            holder.bind(list[position])
        }
    
        override fun getItemCount(): Int = list.size
    }

    注意:使用了 ViewBinding,因此需要在 里启用 ,并在 命名保持一致。build.gradleviewBinding trueitem_article.xml

    步骤 9:主界面布局与 活动

    9.1 主布局

    res/layout/activity_main.xml

    <?xml version="1.0" encoding="utf-8"?>
    <androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:layout_width="match_parent"
        android:layout_height="match_parent">
    
        <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
            android:id="@+id/swipeRefresh"
            android:layout_width="match_parent"
            android:layout_height="match_parent">
    
            <androidx.recyclerview.widget.RecyclerView
                android:id="@+id/rvArticles"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:padding="4dp" />
        </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
    
        <com.google.android.material.snackbar.Snackbar
            android:id="@+id/snackbar" />
    
    </androidx.coordinatorlayout.widget.CoordinatorLayout>

说明: 实际不需要在布局里定义,可在代码中通过 动态创建。为简单起见,可以忽略上面的 元素,仅用 即可。SnackbarSnackbar.make(...)<Snackbar>CoordinatorLayout + SwipeRefreshLayout + RecyclerView

建议改为:

<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/rootLayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
        android:id="@+id/swipeRefresh"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/rvArticles"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:padding="4dp" />
    </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

</androidx.coordinatorlayout.widget.CoordinatorLayout>
9.2 主要活动

ui/main/MainActivity.kt

package com.example.newsapp.ui.main

import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.LinearLayoutManager
import com.example.newsapp.data.local.AppDatabase
import com.example.newsapp.data.repository.ArticleRepository
import com.example.newsapp.databinding.ActivityMainBinding
import com.example.newsapp.ui.common.UiState
import com.google.android.material.snackbar.Snackbar

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding
    private lateinit var articleAdapter: ArticleAdapter

    private val viewModel: ArticleViewModel by viewModels {
        // 简单手动注入 Repository
        val db = AppDatabase.getInstance(applicationContext)
        val repository = ArticleRepository(db.articleDao())
        ArticleViewModelFactory(repository)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        setupRecyclerView()
        setupSwipeRefresh()
        observeViewModel()
    }

    private fun setupRecyclerView() {
        articleAdapter = ArticleAdapter { article ->
            article.url?.let { openUrl(it) }
        }
        binding.rvArticles.apply {
            layoutManager = LinearLayoutManager(this@MainActivity)
            adapter = articleAdapter
        }
    }

    private fun setupSwipeRefresh() {
        binding.swipeRefresh.setOnRefreshListener {
            viewModel.refresh()
        }
    }

    private fun observeViewModel() {
        // 观察文章列表
        viewModel.articlesLiveData.observe(this) { state ->
            when (state) {
                is UiState.Success -> {
                    articleAdapter.submitList(state.data)
                }
                is UiState.Loading -> {
                    // 这里通常不会有 Loading,因为 Flow 一开始就返回 Success(emptyList)
                }
                is UiState.Error -> {
                    showSnackBar("本地数据加载失败:${state.throwable.message}")
                }
            }
        }

        // 观察刷新状态
        viewModel.refreshState.observe(this) { state ->
            when (state) {
                is UiState.Loading -> {
                    binding.swipeRefresh.isRefreshing = true
                }
                is UiState.Success -> {
                    binding.swipeRefresh.isRefreshing = false
                }
                is UiState.Error -> {
                    binding.swipeRefresh.isRefreshing = false
                    showSnackBar("刷新失败:${state.throwable.message}")
                }
            }
        }
    }

    private fun showSnackBar(message: String) {
        Snackbar.make(binding.rootLayout, message, Snackbar.LENGTH_LONG).show()
    }

    private fun openUrl(url: String) {
        runCatching {
            val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
            startActivity(intent)
        }
    }
}

步骤 10:Application(可选)

如果你需要在应用启动时初始化某些全局资源(本例中不强制),可以:

App.kt

package com.example.newsapp

import android.app.Application

class App : Application() {
    override fun onCreate() {
        super.onCreate()
        // 全局初始化,如日志、三方 SDK 等
    }
}

并在 中配置:AndroidManifest.xml

<application
    android:name=".App"
    ... >
    ...
</application>

五、方案优势分析

  1. 架构清晰(MVVM + Repository)

    • Activity 只负责 UI 绑定和简单交互;
    • 数据获取与缓存逻辑集中在 Repository;
    • 容易进行单元测试和维护。
  2. 单一数据源(Room)

    • UI 只观察数据库;
    • 网络刷新仅用于更新数据库;
    • 避免“UI 同时观察网络和本地”导致的一致性问题。
  3. 离线可用

    • 首次成功加载后,就有本地缓存;
    • 没有网络时仍能展示旧数据,提升用户体验。
  4. 现代异步方案(协程 + Flow)

    • Retrofit + 协程:写法同步、执行异步;
    • Room + Flow:数据变更自动推送到 UI,减少手动回调和监听。
  5. 易于扩展

    • 可轻松增加分页(Paging 3)、错误重试、更多字段等;
    • 可替换数据源(例如用别的 API 或 GraphQL)而不影响 ViewModel / UI。

六、总结与进一步扩展建议

本文完整演示了一个真实且可运行的 Android 示例项目,涵盖:

  • 基本架构设计(MVVM + Repository);
  • 数据层实现(Room + Retrofit);
  • 异步与响应式数据流(Kotlin 协程 + Flow);
  • UI 层(RecyclerView + SwipeRefreshLayout + Snackbar);
  • 从“创建项目”到核心代码文件的全流程。

在此基础上,你可以继续扩展:

  1. 引入 Paging 3

    • 实现分页加载新闻列表,优化大数据集的性能。
  2. 使用 Hilt 或 Koin 管理依赖

    • 解除手动创建 Repository / Database 的耦合。
  3. 改用 Jetpack Compose 实现 UI

    • 用 Compose 代替 RecyclerView + XML,更现代的声明式 UI 写法。
  4. 增加收藏、搜索、分类等功能

    • 使用 Room 的更多查询能力,实现复杂过滤和排序。
  5. 增加单元测试与 UI 测试

    • 为 Repository 和 ViewModel 编写单元测试,保证逻辑正确性。
Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐