跳转至

案例3:新闻阅读App完整实现

📋 案例概述

项目简介

构建一个功能完整的新闻阅读应用,展示新闻列表、详情、分类浏览、搜索、收藏等功能。本案例重点展示分页加载、WebView集成、离线阅读等高级特性。

预计开发时间: 6-8小时 难度等级: ⭐⭐⭐⭐ 涉及知识点: Paging3、WebView、Room数据库、搜索功能、分享功能

功能特性

  • 📰 新闻分类浏览(科技、体育、财经、娱乐等)
  • 🔍 实时搜索与搜索历史
  • 📄 新闻详情WebView展示
  • ❤️ 新闻收藏与离线阅读
  • 📤 分享功能集成
  • 🌙 深色模式适配
  • 📶 离线模式支持

🏗️ 项目架构

Text Only
app/
├── data/
│   ├── local/
│   │   ├── NewsDatabase.kt
│   │   ├── NewsDao.kt
│   │   ├── SearchHistoryDao.kt
│   │   └── entity/
│   │       ├── NewsEntity.kt
│   │       └── SearchHistoryEntity.kt
│   ├── remote/
│   │   ├── NewsApiService.kt
│   │   ├── NewsPagingSource.kt
│   │   └── dto/
│   │       ├── NewsResponse.kt
│   │       └── ArticleDto.kt
│   ├── repository/
│   │   └── NewsRepository.kt
│   └── model/
│       └── News.kt
├── ui/
│   ├── home/
│   │   ├── HomeScreen.kt
│   │   ├── HomeViewModel.kt
│   │   └── components/
│   │       ├── NewsItem.kt
│   │       ├── CategoryTabs.kt
│   │       └── BreakingNewsSection.kt
│   ├── search/
│   │   ├── SearchScreen.kt
│   │   ├── SearchViewModel.kt
│   │   └── components/
│   │       ├── SearchBar.kt
│   │       └── SearchHistoryList.kt
│   ├── detail/
│   │   ├── NewsDetailScreen.kt
│   │   └── NewsDetailViewModel.kt
│   ├── favorites/
│   │   ├── FavoritesScreen.kt
│   │   └── FavoritesViewModel.kt
│   └── components/
│       ├── EmptyState.kt
│       ├── ErrorMessage.kt
│       └── LoadingIndicator.kt
└── navigation/
    └── NewsNavGraph.kt

🛠️ 技术实现

1. 数据层实现

News.kt (Domain Model)

Kotlin
// 新闻领域模型,用于UI层展示(与数据库实体和网络DTO解耦)
data class News(
    val id: String,
    val title: String,
    val description: String,
    val url: String,
    val imageUrl: String?,
    val author: String?,
    val publishedAt: LocalDateTime,
    val category: String,
    val source: String,
    val isFavorite: Boolean = false
)

NewsEntity.kt

Kotlin
// Room数据库实体,对应news表
@Entity(tableName = "news")
data class NewsEntity(
    @PrimaryKey  // 使用新闻ID作为主键,避免重复插入
    val id: String,
    val title: String,
    val description: String,
    val url: String,
    val imageUrl: String?,
    val author: String?,
    val publishedAt: Long,
    val category: String,
    val source: String,
    val isFavorite: Boolean,
    val cachedAt: Long = System.currentTimeMillis()  // 缓存时间戳,用于清理过期数据
)

// 扩展函数:将数据库实体转换为领域模型(时间戳转LocalDateTime)
fun NewsEntity.toNews(): News = News(
    id = id,
    title = title,
    description = description,
    url = url,
    imageUrl = imageUrl,
    author = author,
    // 将毫秒时间戳转换为本地日期时间
    publishedAt = Instant.ofEpochMilli(publishedAt)
        .atZone(ZoneId.systemDefault())
        .toLocalDateTime(),
    category = category,
    source = source,
    isFavorite = isFavorite
)

NewsDao.kt

Kotlin
@Dao
interface NewsDao {
    // 按分类查询新闻,返回PagingSource支持分页加载
    @Query("SELECT * FROM news WHERE category = :category ORDER BY publishedAt DESC")
    fun getNewsByCategory(category: String): PagingSource<Int, NewsEntity>

    // 查询所有收藏新闻,返回Flow实现数据变化自动通知
    @Query("SELECT * FROM news WHERE isFavorite = 1 ORDER BY publishedAt DESC")
    fun getFavoriteNews(): Flow<List<NewsEntity>>

    // 模糊搜索标题和描述,支持本地离线搜索
    @Query("SELECT * FROM news WHERE title LIKE '%' || :query || '%' OR description LIKE '%' || :query || '%'")
    suspend fun searchNews(query: String): List<NewsEntity>

    // 批量插入,冲突时替换旧数据(REPLACE策略)
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertAll(news: List<NewsEntity>)

    @Update
    suspend fun update(news: NewsEntity)

    // 更新单条新闻的收藏状态(局部更新,性能优于全量Update)
    @Query("UPDATE news SET isFavorite = :isFavorite WHERE id = :newsId")
    suspend fun updateFavoriteStatus(newsId: String, isFavorite: Boolean)

    // 清理过期缓存,传入截止时间戳
    @Query("DELETE FROM news WHERE cachedAt < :timestamp")
    suspend fun deleteOldNews(timestamp: Long)

    @Query("SELECT COUNT(*) FROM news WHERE category = :category")
    suspend fun getNewsCount(category: String): Int
}

NewsApiService.kt

Kotlin
// Retrofit API接口定义,对接NewsAPI服务
interface NewsApiService {
    // 获取分类头条新闻(支持分页)
    @GET("v2/top-headlines")
    suspend fun getTopHeadlines(
        @Query("category") category: String,
        @Query("page") page: Int,
        @Query("pageSize") pageSize: Int,
        @Query("apiKey") apiKey: String = BuildConfig.NEWS_API_KEY  // API密钥从BuildConfig读取
    ): NewsResponse

    // 全文搜索新闻(默认按发布时间排序)
    @GET("v2/everything")
    suspend fun searchNews(
        @Query("q") query: String,
        @Query("page") page: Int,
        @Query("pageSize") pageSize: Int,
        @Query("sortBy") sortBy: String = "publishedAt",
        @Query("apiKey") apiKey: String = BuildConfig.NEWS_API_KEY
    ): NewsResponse
}

NewsPagingSource.kt

Kotlin
// Paging3分页数据源:负责从网络加载新闻并缓存到本地
class NewsPagingSource(
    private val api: NewsApiService,
    private val category: String,
    private val newsDao: NewsDao
) : PagingSource<Int, News>() {

    // 刷新时确定从哪一页开始加载(基于当前锚点位置计算)
    override fun getRefreshKey(state: PagingState<Int, News>): Int? {
        return state.anchorPosition?.let { anchorPosition ->
            state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
                ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
        }
    }

    // 核心加载方法:尝试网络请求,失败则回退到本地缓存
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, News> {
        return try {
            val page = params.key ?: 1  // 首次加载从第1页开始
            val response = api.getTopHeadlines(
                category = category,
                page = page,
                pageSize = params.loadSize
            )

            val news = response.articles.map { it.toNews(category) }

            // 缓存到本地数据库
            newsDao.insertAll(news.map { it.toEntity() })

            // 构造分页结果:第1页无前页,空数据表示已到末尾
            LoadResult.Page(
                data = news,
                prevKey = if (page == 1) null else page - 1,
                nextKey = if (response.articles.isEmpty()) null else page + 1
            )
        } catch (e: Exception) {
            // 网络错误时尝试从本地加载
            val localNews = newsDao.getNewsByCategory(category)
                .load(params as PagingSource.LoadParams<Int>)

            when (localNews) {
                is LoadResult.Page -> localNews
                is LoadResult.Error -> LoadResult.Error(e)
                else -> LoadResult.Error(e)
            }
        }
    }
}

2. Repository层

Kotlin
// 仓库层:统一管理数据来源(网络+本地),对ViewModel暴露干净的API
class NewsRepository @Inject constructor(
    private val api: NewsApiService,
    private val newsDao: NewsDao,
    private val searchHistoryDao: SearchHistoryDao
) {
    // 获取分页新闻流,UI层通过collectAsLazyPagingItems收集
    fun getNewsStream(category: String): Flow<PagingData<News>> {
        return Pager(
            config = PagingConfig(
                pageSize = 20,               // 每页加载20条
                enablePlaceholders = false,   // 不使用占位符
                maxSize = 100                 // 内存中最多缓存100条
            ),
            pagingSourceFactory = { NewsPagingSource(api, category, newsDao) }
        ).flow
    }

    fun getFavoriteNews(): Flow<List<News>> {
        return newsDao.getFavoriteNews()
            .map { entities -> entities.map { it.toNews() } }
    }

    suspend fun toggleFavorite(newsId: String, isFavorite: Boolean) {
        newsDao.updateFavoriteStatus(newsId, isFavorite)
    }

    // 搜索新闻:优先网络搜索,失败时降级为本地搜索
    suspend fun searchNews(query: String): List<News> {
        // 保存搜索历史
        searchHistoryDao.insert(SearchHistoryEntity(query = query))

        return try {
            val response = api.searchNews(query = query, page = 1, pageSize = 50)
            response.articles.map { it.toNews("search") }
        } catch (e: Exception) {
            // 网络异常时回退到本地数据库模糊搜索
            newsDao.searchNews(query).map { it.toNews() }
        }
    }

    fun getSearchHistory(): Flow<List<String>> {
        return searchHistoryDao.getRecentSearches()
            .map { entities -> entities.map { it.query } }
    }

    suspend fun clearSearchHistory() {
        searchHistoryDao.clearAll()
    }

    suspend fun deleteSearchQuery(query: String) {
        searchHistoryDao.deleteQuery(query)
    }
}

3. UI层实现

HomeScreen.kt

Kotlin
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
    viewModel: HomeViewModel = hiltViewModel(),
    onNewsClick: (News) -> Unit,
    onSearchClick: () -> Unit
) {
    // 新闻分类列表
    val categories = listOf("general", "technology", "business", "sports", "entertainment", "science", "health")
    var selectedCategory by remember { mutableStateOf(categories.first()) }
    // 将Paging数据流收集为LazyPagingItems,供LazyColumn消费
    val newsItems = viewModel.getNewsStream(selectedCategory).collectAsLazyPagingItems()

    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("新闻资讯") },
                actions = {
                    IconButton(onClick = onSearchClick) {
                        Icon(Icons.Default.Search, contentDescription = "搜索")
                    }
                }
            )
        }
    ) { padding ->
        Column(modifier = Modifier.padding(padding)) {
            // 分类标签
            CategoryTabs(
                categories = categories,
                selectedCategory = selectedCategory,
                onCategorySelected = { selectedCategory = it }
            )

            // 新闻列表
            NewsList(
                newsItems = newsItems,
                onNewsClick = onNewsClick,
                onFavoriteClick = { news, isFavorite ->
                    viewModel.toggleFavorite(news.id, isFavorite)
                }
            )
        }
    }
}

@Composable
fun NewsList(
    newsItems: LazyPagingItems<News>,
    onNewsClick: (News) -> Unit,
    onFavoriteClick: (News, Boolean) -> Unit
) {
    LazyColumn {
        items(
            count = newsItems.itemCount,
            key = { index -> newsItems[index]?.id ?: index }  // 使用新闻ID作为key,优化列表重组性能
        ) { index ->
            val news = newsItems[index]
            if (news != null) {
                NewsItemCard(
                    news = news,
                    onClick = { onNewsClick(news) },
                    onFavoriteClick = { onFavoriteClick(news, !news.isFavorite) }
                )
            }
        }

        // Paging加载状态处理:分别处理首次加载(refresh)和追加加载(append)
        newsItems.apply {
            when {
                loadState.refresh is LoadState.Loading -> {
                    item { FullScreenLoading() }  // 首次加载显示全屏loading
                }
                loadState.refresh is LoadState.Error -> {
                    item {
                        ErrorMessage(
                            message = "加载失败,请重试",
                            onRetry = { retry() }
                        )
                    }
                }
                loadState.append is LoadState.Loading -> {
                    item { LoadingIndicator() }
                }
                loadState.append is LoadState.Error -> {
                    item {
                        ErrorMessage(
                            message = "加载更多失败",
                            onRetry = { retry() }
                        )
                    }
                }
            }
        }
    }
}

NewsItemCard.kt

Kotlin
@Composable
fun NewsItemCard(
    news: News,
    onClick: () -> Unit,
    onFavoriteClick: () -> Unit,
    modifier: Modifier = Modifier
) {
    Card(
        onClick = onClick,
        modifier = modifier
            .fillMaxWidth()
            .padding(horizontal = 16.dp, vertical = 8.dp),
        elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
    ) {
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(12.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            // 新闻图片(使用Coil异步加载)
            AsyncImage(
                model = ImageRequest.Builder(LocalContext.current)
                    .data(news.imageUrl)
                    .crossfade(true)  // 启用淡入动画
                    .build(),
                contentDescription = null,
                modifier = Modifier
                    .size(100.dp)
                    .clip(RoundedCornerShape(8.dp)),  // 圆角裁剪
                contentScale = ContentScale.Crop,
                placeholder = painterResource(R.drawable.placeholder_news),  // 加载中占位图
                error = painterResource(R.drawable.placeholder_news)  // 加载失败兜底图
            )

            Spacer(modifier = Modifier.width(12.dp))

            // 新闻内容
            Column(
                modifier = Modifier.weight(1f)
            ) {
                Text(
                    text = news.title,
                    style = MaterialTheme.typography.titleSmall,
                    maxLines = 2,
                    overflow = TextOverflow.Ellipsis
                )

                Spacer(modifier = Modifier.height(4.dp))

                Text(
                    text = news.description,
                    style = MaterialTheme.typography.bodySmall,
                    color = MaterialTheme.colorScheme.onSurfaceVariant,
                    maxLines = 2,
                    overflow = TextOverflow.Ellipsis
                )

                Spacer(modifier = Modifier.height(8.dp))

                Row(
                    verticalAlignment = Alignment.CenterVertically
                ) {
                    Text(
                        text = news.source,
                        style = MaterialTheme.typography.labelSmall,
                        color = MaterialTheme.colorScheme.primary
                    )

                    Spacer(modifier = Modifier.width(8.dp))

                    Text(
                        text = formatTimeAgo(news.publishedAt),
                        style = MaterialTheme.typography.labelSmall,
                        color = MaterialTheme.colorScheme.onSurfaceVariant
                    )

                    Spacer(modifier = Modifier.weight(1f))

                    // 收藏按钮:根据收藏状态切换图标和颜色
                    IconButton(onClick = onFavoriteClick) {
                        Icon(
                            imageVector = if (news.isFavorite)
                                Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
                            contentDescription = "收藏",
                            tint = if (news.isFavorite) Color.Red else LocalContentColor.current
                        )
                    }
                }
            }
        }
    }
}

// 将发布时间格式化为"x分钟前/x小时前/x天前"的友好显示
fun formatTimeAgo(dateTime: LocalDateTime): String {
    val now = LocalDateTime.now()
    val duration = Duration.between(dateTime, now)

    return when {
        duration.toMinutes() < 60 -> "${duration.toMinutes()}分钟前"
        duration.toHours() < 24 -> "${duration.toHours()}小时前"
        duration.toDays() < 7 -> "${duration.toDays()}天前"
        else -> dateTime.format(DateTimeFormatter.ofPattern("MM-dd"))  // 超过7天显示日期
    }
}

SearchScreen.kt

Kotlin
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchScreen(
    viewModel: SearchViewModel = hiltViewModel(),
    onNewsClick: (News) -> Unit,
    onBackClick: () -> Unit
) {
    var query by remember { mutableStateOf("") }  // 搜索关键词状态
    val searchHistory by viewModel.searchHistory.collectAsState()  // 搜索历史
    val searchResults by viewModel.searchResults.collectAsState()  // 搜索结果
    val isSearching by viewModel.isSearching.collectAsState()  // 搜索加载状态

    Scaffold(
        topBar = {
            TopAppBar(
                title = {
                    OutlinedTextField(
                        value = query,
                        onValueChange = { query = it },
                        placeholder = { Text("搜索新闻...") },
                        singleLine = true,
                        modifier = Modifier.fillMaxWidth(),
                        keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),  // 键盘显示搜索按钮
                        keyboardActions = KeyboardActions(
                            onSearch = {  // 点击键盘搜索按钮时触发
                                if (query.isNotBlank()) {
                                    viewModel.search(query)
                                }
                            }
                        ),
                        trailingIcon = {
                            if (query.isNotEmpty()) {
                                IconButton(onClick = { query = "" }) {
                                    Icon(Icons.Default.Clear, contentDescription = "清除")
                                }
                            }
                        }
                    )
                },
                navigationIcon = {
                    IconButton(onClick = onBackClick) {
                        Icon(Icons.Default.ArrowBack, contentDescription = "返回")
                    }
                }
            )
        }
    ) { padding ->
        Box(modifier = Modifier.padding(padding)) {
            // 根据搜索状态显示不同内容
            when {
                isSearching -> FullScreenLoading()  // 搜索中显示加载动画
                searchResults.isNotEmpty() -> SearchResultsList(
                    results = searchResults,
                    onNewsClick = onNewsClick
                )
                query.isEmpty() -> SearchHistoryPanel(  // 未输入时显示搜索历史
                    history = searchHistory,
                    onHistoryClick = {
                        query = it
                        viewModel.search(it)
                    },
                    onClearHistory = { viewModel.clearHistory() },
                    onDeleteHistoryItem = { viewModel.deleteHistoryItem(it) }
                )
                else -> EmptyState(message = "未找到相关新闻")
            }
        }
    }
}

NewsDetailScreen.kt

Kotlin
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NewsDetailScreen(
    newsUrl: String,
    newsTitle: String,
    viewModel: NewsDetailViewModel = hiltViewModel(),
    onBackClick: () -> Unit
) {
    var isLoading by remember { mutableStateOf(true) }
    var webView by remember { mutableStateOf<WebView?>(null) }
    val context = LocalContext.current

    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text(newsTitle, maxLines = 1, overflow = TextOverflow.Ellipsis) },
                navigationIcon = {
                    IconButton(onClick = onBackClick) {
                        Icon(Icons.Default.ArrowBack, contentDescription = "返回")
                    }
                },
                actions = {
                    IconButton(onClick = {
                        webView?.reload()
                    }) {
                        Icon(Icons.Default.Refresh, contentDescription = "刷新")
                    }
                    IconButton(onClick = {
                        shareNews(context, newsTitle, newsUrl)
                    }) {
                        Icon(Icons.Default.Share, contentDescription = "分享")
                    }
                }
            )
        }
    ) { padding ->
        Box(modifier = Modifier.padding(padding)) {
            AndroidView(
                factory = { context ->
                    // 在Compose中嵌入WebView加载新闻原文
                    WebView(context).apply {
                        webViewClient = object : WebViewClient() {
                            override fun onPageFinished(view: WebView?, url: String?) {
                                isLoading = false  // 页面加载完成后隐藏进度条
                            }
                        }
                        webChromeClient = WebChromeClient()  // 支持JavaScript对话框等Chrome功能
                        settings.apply {
                            javaScriptEnabled = true   // 启用JS(大部分新闻网站需要)
                            domStorageEnabled = true   // 启用DOM存储
                            cacheMode = WebSettings.LOAD_DEFAULT  // 使用默认缓存策略
                        }
                        loadUrl(newsUrl)
                        webView = this  // 保存引用,供刷新按钮使用
                    }
                },
                modifier = Modifier.fillMaxSize()
            )

            if (isLoading) {
                CircularProgressIndicator(
                    modifier = Modifier.align(Alignment.Center)
                )
            }
        }
    }
}

// 调用系统分享Sheet分享新闻链接
private fun shareNews(context: Context, title: String, url: String) {
    val shareIntent = Intent(Intent.ACTION_SEND).apply {
        type = "text/plain"  // MIME类型为纯文本
        putExtra(Intent.EXTRA_SUBJECT, title)
        putExtra(Intent.EXTRA_TEXT, "$title\n$url")  // 分享内容:标题+链接
    }
    context.startActivity(Intent.createChooser(shareIntent, "分享新闻"))
}

4. ViewModel实现

Kotlin
@HiltViewModel  // Hilt注入ViewModel,自动管理生命周期
class HomeViewModel @Inject constructor(
    private val repository: NewsRepository
) : ViewModel() {

    fun getNewsStream(category: String): Flow<PagingData<News>> {
        return repository.getNewsStream(category)
            .cachedIn(viewModelScope)  // 在ViewModel作用域内缓存分页数据,避免配置变更时重新加载
    }

    fun toggleFavorite(newsId: String, isFavorite: Boolean) {
        viewModelScope.launch {  // 在协程中执行数据库操作
            repository.toggleFavorite(newsId, isFavorite)
        }
    }
}

@HiltViewModel
class SearchViewModel @Inject constructor(
    private val repository: NewsRepository
) : ViewModel() {

    // MutableStateFlow用于ViewModel内部修改,StateFlow用于对外只读暴露
    private val _searchResults = MutableStateFlow<List<News>>(emptyList())
    val searchResults: StateFlow<List<News>> = _searchResults.asStateFlow()

    private val _isSearching = MutableStateFlow(false)
    val isSearching: StateFlow<Boolean> = _isSearching.asStateFlow()

    // stateIn将Flow转换为StateFlow,Lazily表示首次订阅时才开始收集
    val searchHistory: StateFlow<List<String>> = repository.getSearchHistory()
        .stateIn(viewModelScope, SharingStarted.Lazily, emptyList())

    fun search(query: String) {
        viewModelScope.launch {
            _isSearching.value = true
            try {
                _searchResults.value = repository.searchNews(query)
            } catch (e: Exception) {
                _searchResults.value = emptyList()
            } finally {
                _isSearching.value = false
            }
        }
    }

    fun clearHistory() {
        viewModelScope.launch {
            repository.clearSearchHistory()
        }
    }

    fun deleteHistoryItem(query: String) {
        viewModelScope.launch {
            repository.deleteSearchQuery(query)
        }
    }
}

🔧 依赖配置

Kotlin
// build.gradle.kts (Module: app)
dependencies {
    // Paging - 分页加载库
    implementation("androidx.paging:paging-runtime-ktx:3.2.1")
    implementation("androidx.paging:paging-compose:3.2.1")  // Compose集成

    // WebView - 增强型WebView组件
    implementation("androidx.webkit:webkit:1.9.0")

    // Coil - Compose专用图片异步加载库
    implementation("io.coil-kt:coil-compose:2.5.0")

    // Retrofit - 网络请求框架
    implementation("com.squareup.retrofit2:retrofit:2.11.0")
    implementation("com.squareup.retrofit2:converter-gson:2.11.0")  // JSON解析转换器

    // Room - 本地数据库ORM框架
    implementation("androidx.room:room-runtime:2.6.1")
    implementation("androidx.room:room-ktx:2.6.1")  // 协程支持扩展
    kapt("androidx.room:room-compiler:2.6.1")  // 编译期注解处理器

    // Hilt - 依赖注入框架
    implementation("com.google.dagger:hilt-android:2.50")
    kapt("com.google.dagger:hilt-compiler:2.50")  // 编译期代码生成
    implementation("androidx.hilt:hilt-navigation-compose:1.1.0")  // Compose导航集成
}

🎯 练习任务

基础任务

  1. ✅ 完成新闻列表的分页加载功能
  2. ✅ 实现新闻分类切换
  3. ✅ 添加新闻收藏功能

进阶任务

  1. 🔄 实现新闻详情页的离线阅读(保存网页内容)
  2. 🔄 添加新闻推送通知功能
  3. 🔄 实现新闻阅读历史记录

挑战任务

  1. 🎯 集成Google AdMob广告
  2. 🎯 实现新闻内容的文字转语音功能
  3. 🎯 添加新闻评论功能(需要后端支持)

📚 学习要点

  1. Paging3: 掌握分页加载的最佳实践
  2. WebView: 学会处理WebView的各种场景
  3. 搜索功能: 实现搜索历史和实时搜索
  4. 分享功能: 使用系统分享Sheet
  5. 离线支持: 实现数据的本地缓存策略

🔗 相关章节