☰
Kotlin MVP实战:高尔夫赛事管理应用架构与离线优先设计
2026/10/22 10:41:48 网站建设 项目流程

简介:这是一套基于Kotlin语言、采用MVP架构开发的高尔夫球运动管理应用源码,面向Android开发者与高尔夫行业信息化从业者,可用于学习移动端分层架构设计,或作为赛事管理、成绩追踪类项目的二次开发基础。资源包共75个文件,约191KB,以28个kt业务代码、15个xml界面布局、10个png图片资源为主,另含5个java文件、4个gradle构建脚本及properties、md说明等配置文档,整体结构完整、便于导入Android Studio运行调试。应用覆盖赛事创建与实时赛况更新、逐洞逐杆成绩记录与分析、附近球场信息查询、装备智能推荐、教学视频、社区交流、赛事直播及数据报告等模块,能帮助读者理解MVP模式下各功能层的数据流转与界面组织方式。目前已有49人学习下载,适合希望掌握Kotlin实战与移动端运动管理应用架构的开发者参考借鉴。

1. 从一份 Kotlin 源码包说起:高尔夫运动管理应用到底在管什么

一个做 Android 的朋友最近接了个私活,对方丢过来一个压缩包,标题长得像一串关键词堆出来的清单:赛事管理、成绩追踪、球场查询、装备推荐、教学视频、社区交流、赛事直播、数据分析,全塞在一个 Kotlin 项目里。他第一反应是“这不就是个缝合怪”,第二反应是“MVP 架构现在还有人用?”——这两个判断其实都值得掰开说。

这个标题真正描述的不是八个功能,而是一类典型的垂直运动管理应用:以高尔夫为场景,把“人(球员)、场(球场)、事(赛事)、内容(教学/装备/社区)”四类实体串成一条数据链。它解决的核心问题是:业余球员打完一场球,成绩散落在各种记分卡和聊天记录里,赛事组织方靠 Excel 拉人,球场信息靠口口相传。这套应用想做的就是把这些收进一个 App。

适合谁看?如果你手上有一个 Kotlin 项目要接手或重构,或者你想自己搭一个“赛事 + 成绩 + 内容”的垂直管理应用,这篇的路径可以直接抄。MVP 在这里不是过时,而是在没有成熟架构团队时最不容易翻车的选择——View 和逻辑分离,测试好写,后期换 MVVM 也不用推倒重来。

2. MVP 在 Kotlin 里的落地:从 Contract 到 Presenter 的完整链路

2.1 为什么这个场景选 MVP 而不是直接上 MVVM

先说选型理由。高尔夫管理应用有一个很明显的特征:页面多、状态碎、数据源杂。赛事列表要分页,球员成绩要按轮次聚合,球场信息要读本地缓存加远程接口,教学视频还要处理播放器生命周期。如果一上来就用 MVVM + DataBinding,新手很容易把业务逻辑写进 ViewModel 的onCleared里,或者把 LiveData 当全局变量用,最后调试时根本不知道数据从哪来。

MVP 的好处是边界硬。View 只负责渲染和转发用户操作,Presenter 持有 View 的弱引用,Model 层只做数据获取。每个页面一个 Contract 接口,方法签名写清楚,谁调谁一目了然。坏处也明显:接口文件多,样板代码多。但在一个 8 到 10 个页面的应用里,这个成本是可控的。

我一般会按功能模块拆 Contract,而不是按页面拆。比如“赛事”模块下,赛事列表、赛事详情、报名页共用一个TournamentContract,里面用ListView、DetailView、SignUpView三个子接口区分。这样 Presenter 可以复用一部分赛事数据逻辑,不用每个页面都重新拉一遍。

2.2 用 Kotlin 写一个可复用的 BasePresenter 和 BaseView

直接上代码。先定义最基础的契约,所有模块都继承它:

// BaseContract.kt // 定义 MVP 三层的基础契约,泛型 V 限定为 BaseView 的子类 interface BaseContract { interface BaseView { // 显示加载中,由 Presenter 在发起请求前调用 fun showLoading() // 隐藏加载中,请求结束或失败时调用 fun hideLoading() // 统一错误提示,避免每个页面重复写 Toast fun showError(msg: String) } interface BasePresenter<V : BaseView> { // 绑定 View,通常在 Activity/Fragment 的 onStart 中调用 fun attachView(view: V) // 解绑 View,在 onDestroy 中调用,防止内存泄漏 fun detachView() } }

逻辑说明:BaseView只放三个最通用的 UI 反馈方法,不掺业务。BasePresenter用泛型约束 View 类型,attachView和detachView成对出现。参数上没有什么可调的,但要注意detachView里必须把内部持有的view置空,否则 Presenter 如果被后台线程持有,Activity 就回收不了。

接着写一个抽象实现:

// BasePresenterImpl.kt // 所有具体 Presenter 继承这个类,省去重复的 attach/detach 逻辑 abstract class BasePresenterImpl<V : BaseContract.BaseView> : BaseContract.BasePresenter<V> { // 使用可空类型,detach 后置为 null private var view: V? = null override fun attachView(view: V) { this.view = view } override fun detachView() { this.view = null } // 子类通过这个安全方法访问 View,避免空指针 protected fun getView(): V? = view // 判断 View 是否还活着,网络回调里必须先判断 protected fun isViewAttached(): Boolean = view != null }

这里的关键参数是view的可空设计。很多翻车案例都是因为网络请求回来时 Activity 已经销毁,Presenter 还拿着旧引用调showLoading(),直接崩。isViewAttached()就是后悔药,每个异步回调开头加一句判断,能省掉一半的崩溃日志。

2.3 以“球员成绩追踪”为例跑通一条完整数据流

假设我们要做一个球员成绩列表页,展示某球员最近 5 场球的杆数。先写 Contract:

// ScoreContract.kt // 成绩模块的契约,View 和 Presenter 都按这个接口通信 interface ScoreContract { interface View : BaseContract.BaseView { // 数据加载成功后回调,参数是已经排好序的成绩列表 fun onScoresLoaded(scores: List<Score>) // 没有数据时显示空状态 fun onEmpty() } interface Presenter : BaseContract.BasePresenter<View> { // 传入球员 ID,触发加载 fun loadScores(playerId: String) } }

Model 层用一个简单的 Repository 模拟:

// ScoreRepository.kt // 数据仓库,这里用延迟模拟网络请求,实际项目替换为 Retrofit 或 Room class ScoreRepository { suspend fun fetchScores(playerId: String): List<Score> { // 模拟 800ms 网络耗时 delay(800) // 模拟返回数据,实际应从接口或数据库读取 return listOf( Score(playerId, "2024-06-01", 82), Score(playerId, "2024-06-15", 79), Score(playerId, "2024-07-02", 85) ) } }

Presenter 实现:

// ScorePresenter.kt // 成绩模块的 Presenter,持有 Repository 和 View 的弱引用 class ScorePresenter( private val repository: ScoreRepository = ScoreRepository() ) : BasePresenterImpl<ScoreContract.View>(), ScoreContract.Presenter { private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) override fun loadScores(playerId: String) { // 请求前先让 View 显示加载状态 getView()?.showLoading() scope.launch { try { val scores = repository.fetchScores(playerId) // 回调前必须判断 View 是否还存在 if (isViewAttached()) { if (scores.isEmpty()) { getView()?.onEmpty() } else { getView()?.onScoresLoaded(scores.sortedByDescending { it.date }) } } } catch (e: Exception) { // 异常统一走 View 的错误提示 getView()?.showError(e.message ?: "加载失败") } finally { // 无论成功失败都要隐藏加载 getView()?.hideLoading() } } } // 页面销毁时取消协程,避免回调到已销毁的 View fun destroy() { scope.cancel() detachView() } }

参数说明:Dispatchers.Main保证回调在主线程更新 UI;SupervisorJob让单个子协程失败不影响其他请求;sortedByDescending { it.date }按日期倒序,保证最新成绩在最上面。destroy()里先取消协程再解绑 View,顺序不能反,否则取消过程中可能触发一次回调。

View 层(Activity)只需要实现接口:

// ScoreActivity.kt class ScoreActivity : AppCompatActivity(), ScoreContract.View { private lateinit var presenter: ScorePresenter override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) presenter = ScorePresenter() presenter.attachView(this) presenter.loadScores("player_001") } override fun onScoresLoaded(scores: List<Score>) { // 这里更新 RecyclerView,省略具体 Adapter 代码 } override fun onEmpty() { /* 显示空布局 */ } override fun showLoading() { /* 显示进度条 */ } override fun hideLoading() { /* 隐藏进度条 */ } override fun showError(msg: String) { Toast.makeText(this, msg, Toast.LENGTH_SHORT).show() } override fun onDestroy() { presenter.destroy() super.onDestroy() } }

这条链路跑通后,赛事管理、球场查询、装备推荐都可以照这个模板复制。区别只在于 Repository 里换数据源,Presenter 里换排序和过滤逻辑。

3. 赛事、球场、内容三块数据怎么接:接口设计与本地缓存策略

3.1 赛事管理模块的接口分层与分页参数

赛事模块的数据量最大,通常一个地区一年有几十到上百场赛事。如果一次性拉全量,首屏会卡。常见做法是分页 + 本地缓存首页。

接口设计上,我一般把赛事接口拆成三个:

接口用途关键参数返回
/tournament/list赛事列表page, pageSize, city分页赛事摘要
/tournament/detail赛事详情tournamentId完整赛事信息
/tournament/signup报名tournamentId, playerId报名结果

pageSize建议设 20,太小请求频繁,太大首屏渲染慢。city参数用于按城市过滤,如果用户没选城市就传空字符串,后端返回全部。

本地缓存用 Room 存第一页数据,key 用city + page。这样用户断网打开 App 至少能看到上次刷出来的赛事列表。缓存过期时间设 30 分钟,超过就重新拉。

3.2 球场信息查询:GPS 距离计算与离线包

球场查询的核心是“附近球场”。如果后端不支持按经纬度排序,就得在客户端算。用Location.distanceBetween算两点距离,然后按距离升序排。

// 计算用户当前位置到球场的直线距离,单位米 fun calculateDistance( userLat: Double, userLng: Double, courseLat: Double, courseLng: Double ): Float { val results = FloatArray(1) // 使用 Android 原生 API,避免引入额外依赖 Location.distanceBetween(userLat, userLng, courseLat, courseLng, results) return results[0] }

参数说明:distanceBetween返回的是直线距离,不是驾车距离。如果要做“导航到球场”,得调地图 SDK 的路径规划接口。离线包方面,球场基础信息(名称、地址、电话、18 洞标准杆)可以打包进 assets,首次启动时导入 Room,这样没网也能查。

3.3 教学视频与社区交流的数据模型复用

教学视频和社区帖子在数据结构上高度相似:都有标题、封面、作者、发布时间、内容体。区别是视频多一个播放地址和时长,帖子多一个评论列表。

我一般抽一个ContentItem基类:

// 内容基类,视频和帖子共用 open class ContentItem( val id: String, val title: String, val coverUrl: String, val authorId: String, val publishTime: Long ) // 视频继承基类,扩展播放相关字段 class VideoItem( id: String, title: String, coverUrl: String, authorId: String, publishTime: Long, val playUrl: String, val duration: Int // 单位秒 ) : ContentItem(id, title, coverUrl, authorId, publishTime) // 帖子继承基类,扩展互动字段 class PostItem( id: String, title: String, coverUrl: String, authorId: String, publishTime: Long, val commentCount: Int, val likeCount: Int ) : ContentItem(id, title, coverUrl, authorId, publishTime)

这样列表 Adapter 可以写一个泛型基类,减少重复代码。播放器用 ExoPlayer,在onPause里暂停,onResume里恢复,避免后台耗电。

4. 避坑与排查:接手 Kotlin MVP 项目最容易翻车的 5 个点

4.1 现象:旋转屏幕后 Presenter 重新创建,数据丢失

原因:Activity 重建时onCreate又执行了一遍presenter = ScorePresenter(),旧 Presenter 里的数据没了。

解决:用ViewModel托管 Presenter,或者用onRetainCustomNonConfigurationInstance。更简单的做法是在 Presenter 里加内存缓存,重建后先读缓存再决定是否请求。

4.2 现象:网络请求回来时 View 已销毁,报 NullPointerException

原因:协程或 Retrofit 回调没有判断isViewAttached()。

解决:所有异步回调开头加判断,并且在detachView里取消协程。上面ScorePresenter.destroy()就是标准写法。

4.3 现象:赛事列表分页加载重复数据

原因:page参数没有在请求成功后自增,或者缓存 key 没带页码。

解决:在 Presenter 里维护currentPage,请求成功后再currentPage++。缓存 key 用city_page格式,避免不同页互相覆盖。

4.4 现象:球场距离排序结果不对,最近的在最后

原因:distanceBetween返回的是 Float 数组,如果直接拿results[0]比较但没转成统一单位,或者排序时用了降序。

解决:统一用米做单位,排序用sortedBy { it.distance }升序。如果要做“由近到远”,别用sortedByDescending。

4.5 现象:教学视频播放到一半切后台,回来黑屏

原因:ExoPlayer 实例在onStop里被释放了,但onStart没有重新初始化。

解决:把播放器初始化放在onStart,释放放在onStop,并且保存playbackPosition,恢复时seekTo回去。

5. 从能跑到好用:给赛事数据加一层本地聚合与离线优先

最后一章说一个具体技巧:离线优先的赛事数据聚合。很多同类应用一断网就白屏,其实赛事列表、球场信息、甚至球员历史成绩都可以在本地做一层聚合,让 App 在弱网下也能用。

思路是:Room 里建三张表——tournament、course、score。每次网络请求成功后,除了更新 UI,还异步写入 Room。下次打开页面时,Presenter 先读 Room 展示,同时发起网络请求,回来后对比updateTime,有变化再刷新 UI。这样用户感知到的就是“秒开”。

// 离线优先加载:先读缓存,再请求网络 fun loadScoresOfflineFirst(playerId: String) { scope.launch { // 第一步:读本地缓存,有数据先展示 val cached = scoreDao.queryByPlayer(playerId) if (cached.isNotEmpty() && isViewAttached()) { getView()?.onScoresLoaded(cached) } // 第二步:请求网络,成功后更新缓存和 UI try { val remote = repository.fetchScores(playerId) scoreDao.insertAll(remote) if (isViewAttached()) { getView()?.onScoresLoaded(remote.sortedByDescending { it.date }) } } catch (e: Exception) { // 网络失败时,如果本地有数据就不报错,静默降级 if (cached.isEmpty()) { getView()?.showError("网络不可用,且无本地数据") } } } }

参数上,scoreDao.queryByPlayer建议加索引,否则数据量大了查询会慢。insertAll用OnConflictStrategy.REPLACE,保证同一条成绩不会重复。

验证方法很简单:打开开发者选项里的“模拟弱网”或者直接开飞行模式,看列表能不能显示上次的数据。如果能,说明离线层生效了。

我自己的习惯是,每接一个新模块,先不写 UI,先把 Repository 和 Room 的读写跑通,用单元测试验证“有网无网”两条路径。这样后面写 Presenter 和 Activity 时,心里有底,不会写到一半发现数据对不上。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询