简介一款基于Kotlin语言、采用MVP架构的高尔夫球运动管理Android应用项目面向高尔夫爱好者、移动开发者与赛事运营人员。项目覆盖赛事管理、球员成绩追踪、球场信息查询、装备推荐、教学视频、社区交流、赛事直播与数据分析等模块包括赛事创建编辑、实时赛况更新、每洞每杆数据记录、球场布局与设施评价、个性化装备推荐和教学视频库等可作为学习MVP分层架构、Kotlin协程与数据可视化的完整参考。压缩包共75个文件以28个Kotlin源码文件与15个XML布局/资源配置为主另配Java辅助类、Gradle构建脚本、PNG图片素材以及说明文档和附赠资料整包仅191KB结构简洁便于快速导入编译。目前已有49人学习下载适合需要从零搭建高尔夫运动管理应用或研究同类业务场景的开发者参考通过源码可掌握赛事流程设计、成绩数据模型与多模块交互的落地写法。1. 这个标题背后是一整套高尔夫数据闭环MVP 只是最省心的那层先说一个反直觉的结论基于 Kotlin 语言开发的 MVP 架构高尔夫球运动管理应用难点从来不是 MVP 框架本身而是赛事、成绩、球场、装备、视频、社区、直播、统计这八条业务线挤在同一个 App 里时如何让它们互不干扰。你拿到的这个标题看起来像是一个课程作业包但按工程标准拆开它其实是一个完整的体育服务类产品原型。适合谁读准备从单体 Demo 走向模块化开发的 Android 工程师以及想把体育场景做成 MVP 验证产品的人。Kotlin 在这里不只是替代 Java 的语法糖它决定了你能不能用更少的代码把 MVP 三层约束住。2. 为什么高尔夫应用选 Kotlin MVP架构边界、生命周期与协程调度2.1 先分清 MVP、MVVM 与 MVI 的选用边界很多团队拿到这类多模块项目第一反应是 MVVM 或 MVI因为 Google 官方模板就是那套。但实际做过体育赛事类应用的工程师会告诉你当业务里充满「赛事进行中实时改状态、球员成绩随时被录入、直播间和列表页需要同步刷新」这类强交互场景时MVP 的回调式数据流反而是最好追溯的。MVVM 的双向绑定适合表单密集型应用但赛事状态一多响应式链路断了很难查。MVI 的状态收紧确实干净可样板代码多到让人想放弃。MVP 的边界很清楚View 只管展示Model 只管数据和规则Presenter 做翻译官。选 MVP 不是因为高贵而是因为这类应用的核心逻辑集中在成绩计算、赛程状态流转和推荐规则上这些逻辑放在 Presenter 里才能脱离 Android 环境做单元测试。这一点对后续迭代的价值比架构本身的名气重要得多。下表是三个架构在体育管理类 App 里的适配度对比架构数据流适合场景主要代价MVPView-Presenter-Model赛事状态流转、成绩录入、直播间复用接口数量多需要制度化约束MVVMView-ViewModel-Model表单密集型、列表页展示响应式链路排查成本高MVIView-Intent-ViewModel-State复杂状态机样板代码多小团队不建议2.2 用 Kotlin 特性把 MVP 写薄Java 时代 MVP 被人诟病「接口爆炸」因为它每个页面要写 View 接口、Presenter 接口、Model 接口。Kotlin 至少能从三个维度缓解接口默认方法让你不必在 View 里实现所有回调空安全直接消灭 presenter 与 view 之间的 NPE属性委托能把 view 的绑定收拢到基类里。我一般会把 BaseView 做成极薄的空接口只定义两个全局方法 showToast 和 showLoading其余由各页面扩展。然后用协程作用域代替 Java 时代的 Thread Handler。给一个可直接抄的基类open class BasePresenterV : BaseView { protected var view: V? null private set private var scope: CoroutineScope? null fun attachView(view: V) { this.view view scope CoroutineScope(SupervisorJob() Dispatchers.Main) } fun detachView() { view null scope?.cancel() scope null } protected fun T launchIO( onSuccess: (T) - Unit, block: suspend CoroutineScope.() - T ) { val currentScope scope ?: return currentScope.launch { try { val result withContext(Dispatchers.IO) { block() } if (view ! null) onSuccess(result) } catch (e: Exception) { view?.showError(e.message ?: 请求失败) } } } }这段代码背后的逻辑很关键attachView 时创建协程作用域detachView 时一并取消。这里没有用 GlobalScope原因很简单——Presenter 从设计上就不该比 View 活得久。launchIO 把 IO 线程切换和结果回投封装成模板子类只需要写业务。参数说明block 是任意耗时操作onSuccess 在主线程执行且执行前会检查 view 是否为空避免 Activity 销毁后还去调 UI。2.3 生命周期细节Fragment 与 Activity 要区别对待MVP 最常见的翻车点不是写错逻辑而是生命周期没对齐。Activity 场景可以在 onDestroy 里 detachView但 Fragment 必须拆两步onDestroyView 时 detachViewonDestroy 时才释放 Presenter 本身。原因是 Fragment 的 View 可能被销毁重建而 Presenter 里往往缓存着赛事列表、球场筛选条件这些状态值得留到下一次 View 创建时复用。对应到 BaseMvpFragment 里就两句话override fun onDestroyView() { presenter.detachView() super.onDestroyView() } override fun onDestroy() { if (!requireActivity().isChangingConfigurations()) { presenter.release() } super.onDestroy() }注意 requireActivity().isChangingConfigurations() 的判断如果只是屏幕旋转不要把 Presenter 杀掉否则用户旋转手机后要重新拉一次赛事数据。这一条是很多老手也会忽略的细节。Kotlin 写到这里MVP 已经从「接口地狱」变成了「几十行基类 业务子类」的轻量框架。3. 赛事管理、球员成绩、球场与视频六个模块怎么拆才不会变成一坨3.1 先把工程结构立起来分包方式与依赖声明标题里列了八项功能对应到代码层面我建议按业务分包而不是按层次分包。按层次分包ui / data / model会让赛事和球场都堆在 ui 目录里改一次公共组件牵连全 App。更稳妥的做法是按业务模块立 package每个模块自带 mvp 三层com.golf.app ├── tournament // 赛事管理列表、详情、状态流转 ├── score // 球员成绩追踪录入、统计、趋势 ├── course // 球场信息列表、地图、天气 ├── equipment // 装备推荐规则匹配、详情页 ├── video // 教学视频与赛事直播播放器封装 ├── community // 社区交流帖子流、点赞、评论 ├── analytics // 数据统计成绩聚合、可视化 └── common // BaseView、BasePresenter、网络层依赖配置上有一个关键点值得说透。项目里如果混有集成商给的三方 SDK比如直播播放器的 aar不要一股脑 implementation。我建议在 libs 目录统一放 aargradle 里这样写dependencies { implementation org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3 implementation fileTree(dir: libs, include: [*.jar]) compileOnly fileTree(dir: libs, include: [*.aar]) }这里的逻辑说明jar 参与打包aar 如果只是编译时需要、运行期由宿主工程提供的就用 compileOnly。但要注意compilOnly 对 aar 的 fileTree 写法在较新的 Android Gradle Plugin 下可能失效常见做法是逐个文件列出依赖。简单说不要贪通配符直播 SDK 这类运行时依赖老老实实写 implementation。3.2 赛事管理模块MVP 三件套直接落地赛事管理是这个标题里业务最重的模块。核心需求包括赛事列表、赛事详情、进行中状态切换、球员分组。用 MVP 写的话View 接口先定interface MatchListView : BaseView { fun showMatchList(list: ListMatchSummary) fun openMatchDetail(matchId: Long) fun openLivePlayer(streamUrl: String) fun showMatchResult(matchId: Long) }Presenter 侧关注的是赛事状态机。一场球只有三种用户关心的状态未开始、进行中、已完赛。不同状态下点击列表项跳转目标完全不同。这个分支逻辑如果扔进 Activity 的点击监听里测试就无从谈起放进 Presenter 就能用单元测试覆盖class MatchListPresenter( private val repository: MatchRepository ) : BasePresenterMatchListView() { fun loadUpcomingMatches() { launchIO(onSuccess { list - view?.showMatchList(list) }) { repository.fetchUpcomingMatches() } } fun onMatchClick(match: MatchSummary) { when (match.status) { live - view?.openLivePlayer(match.streamUrl) upcoming - view?.openMatchDetail(match.id) else - view?.showMatchResult(match.id) } } }参数说明repository 是 Model 层的入口fetchUpcomingMatches 是挂起函数内部可以组合网络和本地缓存。onMatchClick 没有任何 Android 依赖三个分支对应三条业务路径。以后产品说「未开始也要能看历史战绩」改这里加一个分支就行不碰 UI。3.3 球员成绩追踪记录粒度与数据层设计成绩追踪是高尔夫应用最具体育特色的部分。这里的成绩不是简单记一个总杆数而是要记录每个洞的杆数、推杆数、是否上球道、是否标准杆上果岭。数据模型建议按回合Round和洞HoleResult两级存data class HoleResult( val holeNumber: Int, val par: Int, val strokes: Int, val putts: Int, val fairwayHit: Boolean, val gir: Boolean ) data class RoundSummary( val playerId: String, val tournamentId: Long, val date: String, val holes: ListHoleResult )录入界面往往是一个一个洞录入Presenter 里要维护一个「当前正在录入的洞」的草稿对象。这里有一个隐含坑用户可能录入到第 3 洞时切走回来需要恢复草稿。因此草稿不能放在 Activity要放在 Presenter 里再配合第 2 章的生命周期设计旋转屏幕后草稿不丢。成绩追踪的另一个重点是欠债数据校验。比如某洞 par 是 5用户录了 20 杆虽然规则允许但大概率是误录。MVP 的优势在这一刻体现校验逻辑写进 Presenter一行代码测试。可以快速给出校验方法fun validateHoleResult(result: HoleResult): Boolean { if (result.strokes result.par 10) return false if (result.putts result.strokes) return false return true }3.4 球场信息、教学视频与赛事直播的接入差异球场信息查询本质是 POI 数据 距离计算。常见做法是后端存球场经纬度和设施标签App 端拿到当前定位后做距离排序。距离计算用球面距离公式就够了不需要引入地图 SDK 就先把功能跑通fun calculateDistance( lat1: Double, lon1: Double, lat2: Double, lon2: Double ): Double { val r 6371.0 val dLat Math.toRadians(lat2 - lat1) val dLon Math.toRadians(lon2 - lon1) val a sin(dLat / 2) * sin(dLat / 2) cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) * sin(dLon / 2) * sin(dLon / 2) val c 2 * atan2(sqrt(a), sqrt(1 - a)) return r * c }教学视频和赛事直播是这八个模块里技术门槛最高的。教学视频是点播用系统播放器或 ExoPlayer 都可以赛事直播是拉流需要处理弱网下的缓冲策略和断流重连。我一般会引入一个带 FFmpeg 内核的开源播放器做底层上层用 MVP 包一层 PlayerContract。直播页的 View 接口至少要有这几个能力interface LiveMatchView : BaseView { fun showPlayerLoading(isLoading: Boolean) fun updateLiveScore(hole: Int, score: Int) fun showStreamError(errorCode: Int) fun onBuffering() }这里特别提醒直播播放器通常持有自己的生命周期Presenter 不要直接操作播放器实例而是通过 View 接口回调去驱动。否则 Presenter 单测的时候你会被迫 mock 一个播放器那场面相当痛苦。4. 成绩统计分析、装备推荐与社区动态写在 Presenter 里的商业逻辑4.1 成绩分析用 Kotlin 集合算子完成多维度统计高尔夫应用的数据分析不需要 Hadoop一名球员一场球 18 个洞的结果全量加载进内存用 Kotlin 集合算子就能完成大多数统计。核心指标包括总杆数、与标准杆差值、上球道率、标准杆上果岭率、平均推杆数。这些指标组合起来就是球员的近期表现曲线fun calculatePerformance(results: ListHoleResult): PerformanceSummary { val totalStrokes results.sumOf { it.strokes } val parTotal results.sumOf { it.par } val overPar totalStrokes - parTotal val fairwayHits results.count { it.fairwayHit } val girCount results.count { it.gir } val avgPutts results.map { it.putts }.average() return PerformanceSummary( totalStrokes totalStrokes, overPar overPar, fairwayHitRate fairwayHits / results.size.toFloat(), girRate girCount / results.size.toFloat(), avgPutts avgPutts ) }逻辑说明sumOf、count、average 三个算子把 18 个洞的数据聚合比手写 for 循环至少省十行可读性也更高。参数说明results 是一个回合的洞列表调用方可以传入历史多个回合的数据来算平均表现。这里要注意早交数据与近期数据的权重——产品通常希望你突出最近五场所以调用前先按日期过滤不要全量塞进来。4.2 装备推荐不靠机器学习也能给出合理推荐装备推荐在这个标题里听起来像 AI实际落地大多是一套规则引擎。高尔夫装备推荐的核心逻辑围绕挥杆速度、差点、常用球场类型这三个输入展开。以球杆推荐为例fun recommendDriver( swingSpeedKmh: Double, handicap: Int ): DriverRecommendation { val shaftFlex when { swingSpeedKmh 85 - S swingSpeedKmh 70 - R else - L } val loft when { handicap 5 - 9.0 handicap in 6..15 - 10.5 else - 12.0 } return DriverRecommendation( shaftFlex shaftFlex, loft loft ) }这套规则不需要模型训练但必须有数据支撑。阈值从哪里来来自装备厂商提供的适配表。把厂商给的表格翻译成上面的 when 分支就是 MVP 的 Model 层。这样做的好处是产品可以随时调阈值而不需要重新发版。参数说明swingSpeedKmh 是挥杆速度handicap 是差点两个参数直接决定了杆身硬度和倾角后续还可以加入杆身长度、握把粗细。这个函数可以百分百单元测试把阈值变化的风险锁在测试用例里。4.3 社区交流数据流分页、点赞与缓存社区交流模块容易被人忽略但它是最能体现用户体验的部分。帖子流的分页加载在 MVP 下要处理好两个问题下拉刷新时旧列表还在翻页时不要重复加载。我见过不少翻车现场是用户刷新之后列表把已加载的两页重复拼在一起。关键代码在 Presenter 里用状态位卡住加载动作class CommunityPresenter( private val communityRepository: CommunityRepository ) : BasePresenterCommunityView() { private var currentPage 0 private var isLoading false private var hasNext true fun loadFeed(action: FeedAction) { if (isLoading) return isLoading true val targetPage if (action is FeedAction.Refresh) 0 else currentPage 1 launchIO(onSuccess { feed - currentPage targetPage hasNext feed.hasNext when (action) { is FeedAction.Refresh - view?.showFreshFeed(feed.items) is FeedAction.LoadMore - view?.appendFeed(feed.items) } isLoading false }) { communityRepository.getFeed(page targetPage, pageSize 20) } } }逻辑说明isLoading 是互斥锁防止用户连续上滑触发多个请求action 是 Kotlin 里的封闭类或枚举这里用了密封类表达刷新和加载更多两种意图。参数说明targetPage 决定请求哪一页刷新归零加载更多递增。这套写法不需要 DiffUtil 做复杂比较但服务端hasNext字段要可靠否则翻到底还会空转。5. 避坑高尔夫应用从编译到上线的五个常见坑5.1 坑一Presenter 里的 view 回调穿透导致空指针现象网络请求正常返回回调里调 view?.showMatchList(list)但 App 崩溃在调用处提示 kotlin.KotlinNullPointerException。原因回调执行前 detachView 被调用了虽然用了安全调用符但如果 view 是个自定义类的强引用封装安全调用不一定挡住。解决launchIO 里回调前判断 view 是否为空同时 detachView 时切掉 scope双保险。最保险的做法是让 View 的空判断与协程取消绑定而不是只依赖 view 是否为 null。5.2 坑二Fragment 的 View 已销毁但 ViewPager 还在缓存现象赛事详情页嵌套在 ViewPager 里左右滑两次之后 Fragment 报 「View already detached」或者 setAdapter 崩溃。原因Fragment 的 onDestroyView 执行后Adapter 还持有旧 View而 Presenter 回投数据把它刷新了一下。解决onDestroyView 里 detachView并把 RecyclerView 的 adapter 置空让 Fragment 完全与旧 UI 断开关联。这是很多直播列表页会在测试阶段遇到的经典问题。5.3 坑三libs 目录通配符依赖在编译期通过、运行期缺类现象直播模块引用了 libs 里的播放器 aar编译没有报错安装后一点进直播间就抛 ClassNotFoundException。原因用了 compileOnly fileTree(dir: libs, include: [*.aar])compileOnly 只在编译期生效运行期不参与打包。解决运行期确实需要加载的 aar 改用 implementation。同时注意较新的 Android Gradle Plugin 对 fileTree 通配 aar 支持不稳定常见做法是逐个写implementation files(libs/live_player_3.2.0.aar)5.4 坑四直播 SDK 的 minSdk 与项目不一致现象引入直播 SDK 后项目编译直接报错说 minSdkVersion 需要 23而项目是 21。原因直播播放器内核往往依赖较新的系统 API。解决不要硬把全局 minSdk 拉高应该把直播做成独立 moduleminSdk 只在那个 module 声明为 23主工程保持 21。这样低版本用户看不到直播入口但不影响其他模块使用。5.5 坑五推荐规则写死在代码里产品调一次就得发版现象装备推荐上线后厂商给出新一年的适配表产品要求把挥杆速度阈值从 85 调整到 82结果改了一个数字就得重新走完整条发版流程。原因推荐规则虽然写在 Presenter 里便于测试但阈值本身不该硬编码。解决把推荐规则表做成远端配置MVP 的 Model 层每天拉取一次规则Presenter 每次推荐都从配置读取阈值。这样调参只需要后台改表App 端零改动。代价是规则读取多了一层缓存但这层缓存对性能和稳定性影响可以忽略。6. 进阶用三条检查清单验证 MVP 是否退化再谈一个实战小技巧验证 MVP 有没有写歪我习惯在代码评审时过三关。第一关View 接口里找不到 if/else如果 View 里出现了分支判断说明逻辑没有下沉到 Presenter。第二关Presenter 里不出现 import android.widget 或 android.app 的痕迹一旦出现架构已经从 MVP 退化成「Activity 换了个名字」。第三关Model 层可以直接在本地 JVM 环境跑单元测试不需要 connecting device。这三关能挡住大部分架构腐化。实战层面再分享一个小技巧用 sealed class 统一 View 事件。一个页面的事件多到四处开火时回调接口越写越散。把事件收拢成一个密封类View 侧只需要一个入口处理事件sealed class ViewEvent { data class ShowLoading(val text: String) : ViewEvent() data class ShowError(val msg: String, val canRetry: Boolean) : ViewEvent() data class OpenMatchDetail(val matchId: Long) : ViewEvent() object ClosePage : ViewEvent() }Presenter 回调时只需要 emit 事件View 用 when 表达式消费。这比十个回调方法更好排查也让 MVP 具备了一点可追溯性。我自己的习惯是遇到页面超过三个操作再上这个模式事件不多时硬引入反而累赘。最后说一个教训我以前做赛事模块时把 View 写得很薄薄到连加载动画都让 Activity 自己管。结果改直播页面布局时发现每次调整 UI 都得动 Presenter才知道架构不是越薄越好而是要在「逻辑可测」和「结构清晰」之间找到那条线。把这条线画好Kotlin MVP 这套组合在体育管理类应用里就是最省心的方案。希望帮到你。本文还有配套的精品资源点击获取