Android实战项目⑤ Paging 3开发社交媒体信息流App 完整源码详解

@[TOC](Android实战项目⑤ Paging 3开发社交媒体信息流App 完整源码详解)# Android实战项目⑤ Paging 3开发社交媒体信息流App 完整源码详解> 系列第5篇。学习Paging 3分页加载、下拉刷新、Coil图片异步加载和Shimmer骨架屏动画。---## 项目结构05-social-feed/app/src/main/java/├── com/example/socialfeed/MainActivity.kt├── com/example/socialfeed/MainApplication.kt├── com/example/socialfeed/data/paging/FeedPagingSource.kt├── com/example/socialfeed/data/remote/FeedApi.kt├── com/example/socialfeed/data/remote/MockFeedInterceptor.kt├── com/example/socialfeed/data/remote/dto/FeedResponse.kt├── com/example/socialfeed/data/remote/dto/PostDto.kt├── com/example/socialfeed/di/AppModule.kt├── com/example/socialfeed/domain/model/Post.kt├── com/example/socialfeed/ui/component/PostCard.kt├── com/example/socialfeed/ui/component/ShimmerLoading.kt├── com/example/socialfeed/ui/screen/FeedScreen.kt├── com/example/socialfeed/ui/theme/Theme.kt├── com/example/socialfeed/viewmodel/FeedViewModel.kt文件数 : 14个Kotlin文件---## 完整源码 + 详解### MainActivity.ktkotlinpackage com.example.socialfeedimport android.os.Bundleimport androidx.activity.ComponentActivityimport androidx.activity.compose.setContentimport androidx.activity.enableEdgeToEdgeimport com.example.socialfeed.ui.screen.FeedScreenimport com.example.socialfeed.ui.theme.SocialFeedThemeimport dagger.hilt.android.AndroidEntryPoint@AndroidEntryPoint class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState); enableEdgeToEdge(); setContent { SocialFeedTheme { FeedScreen() } } }}---### MainApplication.ktkotlinpackage com.example.socialfeedimport android.app.Applicationimport dagger.hilt.android.HiltAndroidApp@HiltAndroidApp class MainApplication : Application()---### FeedViewModel.ktkotlinpackage com.example.socialfeed.viewmodelimport androidx.lifecycle.ViewModelimport androidx.lifecycle.viewModelScopeimport androidx.paging.Pagerimport androidx.paging.PagingConfigimport androidx.paging.PagingDataimport androidx.paging.cachedInimport com.example.socialfeed.data.paging.FeedPagingSourceimport com.example.socialfeed.data.remote.FeedApiimport com.example.socialfeed.domain.model.Postimport dagger.hilt.android.lifecycle.HiltViewModelimport kotlinx.coroutines.flow.Flowimport javax.inject.Inject@HiltViewModelclass FeedViewModel @Inject constructor(private val api: FeedApi) : ViewModel() { val feed: Flow<PagingData<Post>> = Pager( config = PagingConfig(pageSize = 20, enablePlaceholders = false), pagingSourceFactory = { FeedPagingSource(api) } ).flow.cachedIn(viewModelScope)}---### Post.ktkotlinpackage com.example.socialfeed.domain.modeldata class Post(val id: Long, val authorName: String, val authorAvatar: String, val content: String, val imageUrl: String? = null, val likeCount: Int = 0, val isLiked: Boolean = false, val timestamp: Long = System.currentTimeMillis())---### FeedApi.ktkotlinpackage com.example.socialfeed.data.remoteimport com.example.socialfeed.data.remote.dto.FeedResponseimport retrofit2.http.GETimport retrofit2.http.Queryinterface FeedApi { @GET("feed") suspend fun getFeed(@Query("page") page: Int, @Query("size") size: Int = 20): FeedResponse}---### MockFeedInterceptor.ktkotlinpackage com.example.socialfeed.data.remoteimport okhttp3.*import okhttp3.MediaType.Companion.toMediaTypeimport okhttp3.ResponseBody.Companion.toResponseBodyimport kotlin.random.Randomclass MockFeedInterceptor : Interceptor { private val images = listOf("https://picsum.photos/seed/a1/400/300","https://picsum.photos/seed/b2/400/300","https://picsum.photos/seed/c3/400/300","https://picsum.photos/seed/d4/400/300",null) private val authors = listOf("小明","小红","技术宅","设计师","摄影爱好者") private val contents = listOf("今天天气真好!出去走走吧","刚学了 Jetpack Compose,太好用了","分享一张照片","推荐一本好书《Kotlin in Action》","周末去爬山了,风景很美") override fun intercept(chain: Interceptor.Chain): Response { val url = chain.request().url val page = url.queryParameter("page")?.toIntOrNull() ?: 1 val size = url.queryParameter("size")?.toIntOrNull() ?: 20 val startId = (page - 1) * size.toLong() val posts = (0 until size).map { i -> val id = startId + i """{"id":$id,"authorName":"${authors[i % authors.size]}","authorAvatar":"https://i.pravatar.cc/150?img=${id % 70}","content":"${contents[i % contents.size]} #${id}","imageUrl":${images[i % images.size]?.let{"\"$it\""} ?: "null"},"likeCount":${Random.nextInt(0,500)},"timestamp":${System.currentTimeMillis() - id * 60000}}""" }.joinToString(",") val body = """{"items":[$posts],"page":$page,"hasMore":${page < 5}}""" return Response.Builder().code(200).request(chain.request()).protocol(Protocol.HTTP_2).message("OK") .body(body.toResponseBody("application/json".toMediaType())).build() }}---### FeedResponse.ktkotlinpackage com.example.socialfeed.data.remote.dtoimport kotlinx.serialization.Serializable@Serializable data class FeedResponse(val items: List<PostDto>, val page: Int, val hasMore: Boolean)---### PostDto.ktkotlinpackage com.example.socialfeed.data.remote.dtoimport kotlinx.serialization.Serializable@Serializable data class PostDto(val id: Long, val authorName: String, val authorAvatar: String, val content: String, val imageUrl: String? = null, val likeCount: Int = 0, val timestamp: Long)---### FeedPagingSource.ktkotlinpackage com.example.socialfeed.data.pagingimport androidx.paging.PagingSourceimport androidx.paging.PagingStateimport com.example.socialfeed.data.remote.FeedApiimport com.example.socialfeed.domain.model.Postclass FeedPagingSource(private val api: FeedApi) : PagingSource<Int, Post>() { override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Post> { val page = params.key ?: 1 return try { val response = api.getFeed(page, params.loadSize) val posts = response.items.map { Post(it.id, it.authorName, it.authorAvatar, it.content, it.imageUrl, it.likeCount, false, it.timestamp) } LoadResult.Page(data = posts, prevKey = if (page == 1) null else page - 1, nextKey = if (response.hasMore) page + 1 else null) } catch (e: Exception) { LoadResult.Error(e) } } override fun getRefreshKey(state: PagingState<Int, Post>) = state.anchorPosition?.let { state.closestPageToPosition(it)?.prevKey?.plus(1) ?: state.closestPageToPosition(it)?.nextKey?.minus(1) }}---### FeedScreen.ktkotlinpackage com.example.socialfeed.ui.screenimport androidx.compose.foundation.layout.*import androidx.compose.foundation.lazy.LazyColumnimport androidx.compose.material3.*import androidx.compose.material3.pulltorefresh.PullToRefreshBoximport androidx.compose.runtime.*import androidx.compose.ui.Alignmentimport androidx.compose.ui.Modifierimport androidx.compose.ui.unit.dpimport androidx.hilt.navigation.compose.hiltViewModelimport androidx.paging.LoadStateimport androidx.paging.compose.collectAsLazyPagingItemsimport com.example.socialfeed.ui.component.PostCardimport com.example.socialfeed.ui.component.ShimmerLoadingimport com.example.socialfeed.viewmodel.FeedViewModel@OptIn(ExperimentalMaterial3Api::class)@Composablefun FeedScreen(viewModel: FeedViewModel = hiltViewModel()) { val posts = viewModel.feed.collectAsLazyPagingItems() val isRefreshing = posts.loadState.refresh is LoadState.Loading Scaffold(topBar = { TopAppBar(title = { Text("社交动态") }) }) { padding -> PullToRefreshBox(isRefreshing = isRefreshing, onRefresh = { posts.refresh() }, modifier = Modifier.padding(padding)) { if (posts.loadState.refresh is LoadState.Loading && posts.itemCount == 0) { ShimmerLoading() } else { LazyColumn(contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { items(count = posts.itemCount, key = { posts[it]?.id ?: it }) { index -> posts[index]?.let { PostCard(it) } } when (posts.loadState.append) { is LoadState.Loading -> item { Box(Modifier.fillMaxWidth(), Alignment.Center) { CircularProgressIndicator(Modifier.padding(16.dp)) } } is LoadState.Error -> item { Button(onClick = { posts.retry() }, Modifier.fillMaxWidth()) { Text("加载失败,点击重试") } } else -> {} } } } } }}---### Theme.ktkotlinpackage com.example.socialfeed.ui.themeimport android.os.Buildimport androidx.compose.foundation.isSystemInDarkThemeimport androidx.compose.material3.*import androidx.compose.runtime.Composableimport androidx.compose.ui.platform.LocalContext@Composablefun SocialFeedTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { val cs = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { val ctx = LocalContext.current; if (darkTheme) dynamicDarkColorScheme(ctx) else dynamicLightColorScheme(ctx) } else if (darkTheme) darkColorScheme() else lightColorScheme() MaterialTheme(colorScheme = cs, content = content)}---### PostCard.ktkotlinpackage com.example.socialfeed.ui.componentimport androidx.compose.foundation.layout.*import androidx.compose.foundation.shape.CircleShapeimport androidx.compose.material.icons.Iconsimport androidx.compose.material.icons.filled.Favoriteimport androidx.compose.material.icons.filled.FavoriteBorderimport androidx.compose.material.icons.filled.Shareimport androidx.compose.material3.*import androidx.compose.runtime.*import androidx.compose.ui.Alignmentimport androidx.compose.ui.Modifierimport androidx.compose.ui.draw.clipimport androidx.compose.ui.layout.ContentScaleimport androidx.compose.ui.unit.dpimport coil3.compose.AsyncImageimport com.example.socialfeed.domain.model.Post@Composablefun PostCard(post: Post) { var liked by remember { mutableStateOf(post.isLiked) } var likeCount by remember { mutableIntStateOf(post.likeCount) } Card(modifier = Modifier.fillMaxWidth(), elevation = CardDefaults.cardElevation(2.dp)) { Column(modifier = Modifier.padding(16.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { AsyncImage(model = post.authorAvatar, contentDescription = null, modifier = Modifier.size(40.dp).clip(CircleShape), contentScale = ContentScale.Crop) Spacer(Modifier.width(12.dp)) Text(post.authorName, style = MaterialTheme.typography.titleSmall) } Spacer(Modifier.height(8.dp)) Text(post.content, style = MaterialTheme.typography.bodyMedium) post.imageUrl?.let { url -> Spacer(Modifier.height(8.dp)) AsyncImage(model = url, contentDescription = null, modifier = Modifier.fillMaxWidth().height(200.dp), contentScale = ContentScale.Crop) } Spacer(Modifier.height(8.dp)) Row { IconButton(onClick = { liked = !liked; likeCount += if (liked) 1 else -1 }) { Icon(if (liked) Icons.Default.Favorite else Icons.Default.FavoriteBorder, "点赞", tint = if (liked) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant) } Text("$likeCount", modifier = Modifier.align(Alignment.CenterVertically), style = MaterialTheme.typography.labelMedium) Spacer(Modifier.weight(1f)) IconButton(onClick = {}) { Icon(Icons.Default.Share, "分享") } } } }}---### ShimmerLoading.ktkotlinpackage com.example.socialfeed.ui.componentimport androidx.compose.animation.core.*import androidx.compose.foundation.backgroundimport androidx.compose.foundation.layout.*import androidx.compose.foundation.shape.CircleShapeimport androidx.compose.foundation.shape.RoundedCornerShapeimport androidx.compose.runtime.Composableimport androidx.compose.ui.Modifierimport androidx.compose.ui.draw.clipimport androidx.compose.ui.geometry.Offsetimport androidx.compose.ui.graphics.Brushimport androidx.compose.ui.graphics.Colorimport androidx.compose.ui.unit.dp@Composablefun ShimmerLoading() { val shimmerColors = listOf(Color.LightGray.copy(0.6f), Color.LightGray.copy(0.2f), Color.LightGray.copy(0.6f)) val transition = rememberInfiniteTransition(label = "shimmer") val translateAnim = transition.animateFloat(initialValue = 0f, targetValue = 1000f, animationSpec = infiniteRepeatable(tween(1200, easing = FastOutSlowInEasing)), label = "shimmer") val brush = Brush.linearGradient(shimmerColors, start = Offset.Zero, end = Offset(translateAnim.value, translateAnim.value)) Column(Modifier.padding(16.dp)) { repeat(3) { Row(Modifier.padding(vertical = 8.dp)) { Spacer(Modifier.size(40.dp).clip(CircleShape).background(brush)) Spacer(Modifier.width(12.dp)) Column { Spacer(Modifier.fillMaxWidth(0.5f).height(14.dp).clip(RoundedCornerShape(4.dp)).background(brush)); Spacer(Modifier.height(8.dp)); Spacer(Modifier.fillMaxWidth(0.3f).height(12.dp).clip(RoundedCornerShape(4.dp)).background(brush)) } } Spacer(Modifier.fillMaxWidth().height(200.dp).clip(RoundedCornerShape(8.dp)).background(brush)) Spacer(Modifier.height(16.dp)) } }}---### AppModule.ktkotlinpackage com.example.socialfeed.diimport com.example.socialfeed.data.remote.FeedApiimport com.example.socialfeed.data.remote.MockFeedInterceptorimport dagger.Moduleimport dagger.Providesimport dagger.hilt.InstallInimport dagger.hilt.components.SingletonComponentimport kotlinx.serialization.json.Jsonimport okhttp3.MediaType.Companion.toMediaTypeimport okhttp3.OkHttpClientimport retrofit2.Retrofitimport retrofit2.converter.kotlinx.serialization.asConverterFactoryimport javax.inject.Singleton@Module @InstallIn(SingletonComponent::class)object AppModule { @Provides @Singleton fun provideOkHttp() = OkHttpClient.Builder().addInterceptor(MockFeedInterceptor()).build() @Provides @Singleton fun provideRetrofit(client: OkHttpClient): Retrofit = Retrofit.Builder().baseUrl("https://mock.api.com/").client(client).addConverterFactory(Json { ignoreUnknownKeys = true }.asConverterFactory("application/json".toMediaType())).build() @Provides @Singleton fun provideFeedApi(retrofit: Retrofit): FeedApi = retrofit.create(FeedApi::class.java)}---## 运行方式1. Android Studio打开 projects/05-social-feed 目录2. Gradle同步完成后Run运行> 系列导航: Android实战系列 1-6 篇。

相关推荐
宋拾壹2 小时前
php网站小程序接入抖音团购核销
android·小程序·php
莫逸风3 小时前
【java-core-collections】B+ 树深度解析
android·java·开发语言
我命由我123453 小时前
Android 开发问题:无法从存储库 “D:\keys\MyNotifications.jks“ 中读取密钥 MyNotifications.
android·java·java-ee·android studio·android jetpack·android-studio·android runtime
AI玫瑰助手3 小时前
Python基础:字符串的切片操作(含正向反向索引)
android·开发语言·python
落羽的落羽3 小时前
【算法札记】练习 | Week2
android·linux·服务器·c++·python·算法·机器学习
ROLL.73 小时前
同步与异步
android·java
BoomHe3 小时前
Android (AAOS) 13 编译中间产物(Wifi Jar)
android·android studio·android jetpack
千里马学框架4 小时前
Android Automotive CarService 和 CarManager 源码剖析
android·车载系统·framework·系统开发·car framework
独隅4 小时前
PyTorch模型转TensorFlow Lite的Android部署全流程指南
android·pytorch·tensorflow