简介:本资源是一套完整的Android校园报修系统毕业设计项目,面向计算机相关专业本科生及移动应用开发初学者,解决高校后勤维修服务数字化管理的实际需求。系统采用Java语言开发,基于Android客户端与JavaWeb后台协同架构,支持普通用户在线报修、订单跟踪、评价反馈与即时聊天,同时为维修人员提供故障处理、耗材登记、公告发布等功能,管理员可通过网页或APP端统一管理用户与工单。压缩包共2个文件(1个SQL建表脚本用于数据库初始化,1个ZIP源码包含完整Android工程与Web项目),总大小8.4MB,结构清晰、模块划分明确,涵盖登录注册、工单流转、状态更新、前后端交互等核心业务逻辑。目前已有534人学习下载,资源附带可直接部署的Oracle/MySQL兼容脚本与可运行源码,适合作为课程设计、毕设参考或Android+Web全栈开发实践范例。
1. 一个能真正跑在学生手机上的校园报修系统,不是后台管理界面的Android翻版
很多高校上线的“智慧后勤”App,点开报修模块后,发现只是把网页表单套了个Android壳——拍照上传失败、定位不准、提交后没反馈、维修进度查不到,学生反复打电话催,宿管老师还在用Excel登记。这不是Android校园报修系统,这是移动端的流程摆设。真正的校园报修系统,必须从Android原生能力出发:利用CameraX实现实时预览与压缩上传、通过FusedLocationProvider精确获取楼栋级位置、用WorkManager保障离线报修数据可靠暂存、以NotificationCompat构建分级提醒(如“已派单”“师傅已在楼下”)。它面向的是课间掏出手机拍下漏水天花板的大一新生,不是坐在办公室点鼠标的技术员。开发重点不在“有没有”,而在“能不能秒拍秒传”“会不会因宿舍WiFi弱而丢单”“维修员接单后能否自动触发短信+通知双通道”。本文聚焦Android端从零落地的完整链路——不依赖H5容器,不嫁接旧Web后台,所有交互、状态、离线逻辑均由Android原生组件驱动。
2. 基于Android Jetpack架构的模块化分层设计
2.1 为什么放弃MVC/MVP,选择MVVM+Clean Architecture组合
校园场景下,报修单状态流转复杂:学生提交→后台审核→分配维修员→维修中→验收→评价,每个环节都可能因网络中断、APP退后台、系统杀进程而丢失状态。传统MVC将网络请求、数据库操作、UI更新全塞进Activity,一旦Activity重建(如横竖屏切换),未完成的上传任务直接丢失;MVP虽解耦了View和Presenter,但Presenter持有Context引用易导致内存泄漏,且无法感知生命周期,在学生切到微信回消息再切回来时,页面常显示“加载中…”卡死。MVVM配合ViewModel+LiveData+DataBinding,天然契合Android生命周期感知需求:ViewModel不持有View引用,配置变更时不销毁;LiveData自动绑定生命周期,避免内存泄漏;DataBinding减少findViewById模板代码,让XML直接绑定状态(如android:visibility="@{repairState.isUploading ? View.VISIBLE : View.GONE}")。Clean Architecture进一步隔离关注点:Domain层定义SubmitRepairUseCase接口,Data层实现RepairRemoteDataSource(对接Retrofit)和RepairLocalDataSource(Room数据库),Presentation层仅消费UseCase结果。当学校后期要接入钉钉审批流或替换为华为快应用时,只需重写Data层实现,Presentation层代码零修改。
2.2 核心模块划分与依赖关系
| 模块 | 职责 | 关键技术选型 | 为何不可替代 |
|---|---|---|---|
| app(Presentation) | Activity/Fragment、DataBinding、状态管理 | ViewModel、LiveData、Navigation Component | 承载用户交互,需严格遵循Android生命周期,不能引入业务逻辑 |
| domain | 用例定义、实体抽象、业务规则 | Kotlin sealed class(如sealed class RepairStatus { data class Pending() : RepairStatus() }) | 纯Kotlin代码,无Android SDK依赖,可复用于其他平台(如KMM) |
| data | 数据源实现、网络请求、本地缓存 | Retrofit 2.9+、Room 2.6+、WorkManager 2.8+ | Room提供编译期SQL校验,避免运行时崩溃;WorkManager保证离线报修任务在设备重启后仍执行 |
| common | 工具类、扩展函数、基础资源 | Timber日志、Glide图片加载、Coroutines | 统一异常处理(如网络错误统一Toast提示)、避免各模块重复造轮子 |
提示:不要在ViewModel中直接调用Retrofit或Room。ViewModel应只调用UseCase,UseCase再协调Data层。例如
SubmitRepairUseCase内部调用repairRemoteDataSource.submit()和repairLocalDataSource.saveDraft(),确保网络成功则清除草稿,失败则保留草稿供重试。
2.3 使用Navigation Component实现单Activity多Fragment导航
校园报修流程存在强路径约束:首页→报修入口→表单页→图片上传→确认页→提交成功页→历史记录页。若用多个Activity,每次跳转都需传递大量参数(如报修类型、楼层信息),且返回栈管理混乱。Navigation Component通过nav_graph.xml集中声明路由:
<!-- res/navigation/nav_graph.xml --> <navigation xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:id="@+id/nav_graph" app:startDestination="@id/homeFragment"> <fragment android:id="@+id/homeFragment" android:name="com.school.repair.ui.home.HomeFragment" android:label="首页" /> <fragment android:id="@+id/submitFragment" android:name="com.school.repair.ui.submit.SubmitFragment" android:label="报修"> <argument android:name="repairType" app:argType="com.school.repair.model.RepairType" android:defaultValue="ELECTRICITY" /> </fragment> <fragment android:id="@+id/historyFragment" android:name="com.school.repair.ui.history.HistoryFragment" android:label="我的报修" /> </navigation>在Activity中仅需初始化一次:
// MainActivity.kt class MainActivity : AppCompatActivity() { private lateinit var navController: NavController override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) val navHostFragment = supportFragmentManager .findFragmentById(R.id.nav_host_fragment) as NavHostFragment navController = navHostFragment.navController // 设置底部导航栏联动 findViewById<BottomNavigationView>(R.id.bottom_nav).setupWithNavController(navController) } }Fragment间跳转不再使用Intent,而是通过navController.navigate(R.id.action_submitFragment_to_historyFragment),参数自动序列化,且支持Deep Link(如点击邮件中的repair://pending/12345直接跳转到待处理单详情)。
3. 关键功能的原生Android实现细节
3.1 拍照上传:CameraX + OkHttp分片上传防超时
学生常需拍摄漏水、破损等现场照片,但宿舍环境光线差、手机存储空间紧张,直接调用系统相机返回Bitmap易OOM,且大图上传易因校园网波动超时。CameraX提供统一API适配不同厂商摄像头,配合ImageCapture用法简洁:
// SubmitFragment.kt private fun bindCameraUseCase() { val imageCapture = ImageCapture.Builder() .setTargetRotation(viewBinding.viewFinder.display.rotation) .setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY) .build() try { imageCapture.takePicture( getOutputFileOptions(), ContextCompat.getMainExecutor(this), object : ImageCapture.OnImageCapturedCallback() { override fun onCaptureSuccess(photo: ImageProxy) { // 在后台线程压缩并保存 lifecycleScope.launch(Dispatchers.IO) { val compressedFile = compressAndSave(photo) // 更新UI显示缩略图 withContext(Dispatchers.Main) { viewBinding.previewImage.setImageBitmap( BitmapFactory.decodeFile(compressedFile.absolutePath) ) } } } } ) } catch (e: Exception) { Toast.makeText(context, "相机启动失败:${e.message}", Toast.LENGTH_SHORT).show() } } private fun getOutputFileOptions(): ImageCapture.OutputFileOptions { val photoFile = File(requireContext().getExternalFilesDir(Environment.DIRECTORY_PICTURES), "repair_${System.currentTimeMillis()}.jpg") return ImageCapture.OutputFileOptions.Builder(photoFile).build() }上传采用OkHttp分片策略,避免单次请求过大:
// RepairRemoteDataSource.kt fun uploadImage(file: File): Flow<Result<String>> = flow { val multipartBody = MultipartBody.Builder() .setType(MultipartBody.FORM) .addFormDataPart("file", file.name, RequestBody.create( MediaType.parse("image/jpeg"), file )) .build() val request = Request.Builder() .url("${baseUrl}/api/v1/upload") .post(multipartBody) .build() val response = client.newCall(request).await() if (response.isSuccessful && response.body != null) { val json = JSONObject(response.body.string()) emit(Result.success(json.getString("url"))) } else { emit(Result.failure(Exception("上传失败:${response.code}"))) } }注意:
getExternalFilesDir()返回路径为/data/data/com.school.repair/files/Pictures/,无需申请READ_EXTERNAL_STORAGE权限,规避Android 11+分区存储限制。上传URL由后台返回,前端只负责展示。
3.2 定位与楼栋智能识别:FusedLocationProvider + 地理围栏
学生常描述不清位置:“三号宿舍楼一楼走廊”,但APP需精确定位到具体房间。单纯GPS在室内误差达20米,需结合WIFI信号强度与已知楼栋坐标库。FusedLocationProvider自动融合GPS、Wi-Fi、基站数据:
// LocationHelper.kt class LocationHelper(private val context: Context) { private val fusedLocationClient = LocationServices.getFusedLocationProviderClient(context) fun getCurrentLocation(): Flow<Location> = callbackFlow { val locationRequest = LocationRequest.create() .setInterval(10000) // 10秒更新一次 .setFastestInterval(5000) .setPriority(LocationRequest.PRIORITY_HIGH_ACCURACY) val locationCallback = object : LocationCallback() { override fun onLocationResult(result: LocationResult?) { result?.lastLocation?.let { trySend(it) } } } fusedLocationClient.requestLocationUpdates(locationRequest, locationCallback, Looper.getMainLooper()) awaitClose { fusedLocationClient.removeLocationUpdates(locationCallback) } } }获取坐标后,查询预置的楼栋GeoJSON数据(内置assets中):
// assets/buildings.json { "features": [ { "properties": { "name": "三号宿舍楼", "floor": 6 }, "geometry": { "type": "Polygon", "coordinates": [[[116.3,39.9],[116.3,39.91],[116.31,39.91],[116.31,39.9],[116.3,39.9]] } } ] }使用Turbo-Geometry库判断坐标是否在多边形内,匹配后自动填充“三号宿舍楼-302室”,学生只需确认无需手动输入。
3.3 离线报修:Room持久化 + WorkManager保障最终一致性
校园网高峰期常断连,学生填完表单点击提交却无响应。此时需将数据存入本地数据库,并在联网后自动同步:
// RepairEntity.kt (Room Entity) @Entity(tableName = "repair_records") data class RepairEntity( @PrimaryKey(autoGenerate = true) val id: Long = 0, val title: String, val description: String, val imageUrl: String?, val status: String = "DRAFT", // DRAFT/SUBMITTED/PROCESSING/DONE val createdAt: Long = System.currentTimeMillis(), val updatedAt: Long = System.currentTimeMillis() ) // RepairDao.kt @Dao interface RepairDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(repair: RepairEntity): Long @Query("SELECT * FROM repair_records WHERE status = 'DRAFT'") suspend fun getDrafts(): List<RepairEntity> @Query("UPDATE repair_records SET status = 'SUBMITTED', updatedAt = :time WHERE id = :id") suspend fun markSubmitted(id: Long, time: Long) }同步任务交由WorkManager调度:
// SyncWorker.kt class SyncWorker( context: Context, params: WorkerParameters ) : CoroutineWorker(context, params) { private val repairDao = (applicationContext as MyApplication).database.repairDao() private val remoteDataSource = RepairRemoteDataSource() override suspend fun doWork(): Result { val drafts = repairDao.getDrafts() if (drafts.isEmpty()) return Result.success() drafts.forEach { draft -> try { val remoteId = remoteDataSource.submitRepair(draft) repairDao.markSubmitted(draft.id, System.currentTimeMillis()) // 发送成功通知 sendSyncSuccessNotification(remoteId) } catch (e: Exception) { // 同步失败,保留DRAFT状态,下次重试 Log.e("SyncWorker", "同步失败:${draft.id}", e) } } return Result.success() } } // 注册周期性同步(每15分钟检查一次) PeriodicWorkRequestBuilder<SyncWorker>( 15, TimeUnit.MINUTES ).build().also { request -> WorkManager.getInstance(applicationContext) .enqueueUniquePeriodicWork( "repair_sync", ExistingPeriodicWorkPolicy.KEEP, request ) }提示:WorkManager的
ExistingPeriodicWorkPolicy.KEEP确保同一任务不会重复注册,避免多实例冲突。同步成功后调用repairDao.markSubmitted()更新状态,防止重复提交。
4. 性能与体验优化实战
4.1 首屏加载加速:SplashScreen API + 预加载关键数据
Android 12+的SplashScreen API可统一启动画面,避免白屏闪动:
// themes.xml <style name="Theme.Splash" parent="Theme.SplashScreen"> <item name="windowSplashScreenBackground">@color/splash_bg</item> <item name="windowSplashScreenAnimatedIcon">@mipmap/ic_launcher_foreground</item> <item name="postSplashScreenTheme">@style/Theme.App</item> </style>在MainActivity中预加载必要数据:
override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen().apply { setKeepOnScreenCondition { !isDataLoaded } } super.onCreate(savedInstanceState) // 预加载楼栋列表、常见报修类型 lifecycleScope.launch { loadBuildingList() loadRepairTypes() isDataLoaded = true } }同时禁用android:exported="false"的启动Activity,防止被第三方恶意调起。
4.2 图片加载与内存控制:Glide定制化配置
宿舍楼道照片常含大量暗部细节,Glide默认压缩会丢失关键信息。需定制Decoder:
// GlideModule.kt class RepairGlideModule : AppGlideModule() { override fun applyOptions(context: Context, builder: GlideBuilder) { builder.setMemorySizeMultiplier(0.5f) // 降低内存占用 builder.setDefaultRequestOptions( RequestOptions() .diskCacheStrategy(DiskCacheStrategy.ALL) .placeholder(R.drawable.ic_photo_placeholder) .error(R.drawable.ic_photo_error) ) } } // 加载时指定高质量解码 Glide.with(context) .load(imageUrl) .override(Target.SIZE_ORIGINAL) // 保持原始尺寸 .decoder(Downsampler.AT_LEAST) .into(imageView)对RecyclerView中的缩略图,强制使用override(300, 300)避免过度解码。
4.3 通知分级与渠道适配:NotificationChannel分组管理
维修状态需差异化提醒:提交成功用IMPORTANCE_LOW(不震动),派单成功用IMPORTANCE_HIGH(震动+响铃),验收完成用IMPORTANCE_DEFAULT(仅状态栏图标):
// NotificationHelper.kt fun createNotificationChannels() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channels = listOf( NotificationChannel( "repair_status", "报修状态", NotificationManager.IMPORTANCE_LOW ).apply { description = "报修单创建、提交成功提醒" enableLights(true) lightColor = Color.BLUE }, NotificationChannel( "repair_assign", "维修派单", NotificationManager.IMPORTANCE_HIGH ).apply { description = "维修员接单、到达提醒" enableVibration(true) vibrationPattern = longArrayOf(0, 500, 200, 500) } ) notificationManager.createNotificationChannels(channels) } }发送时指定渠道ID:
val intent = Intent(context, RepairDetailActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK putExtra("repair_id", repairId) } val pendingIntent = PendingIntent.getActivity( context, 0, intent, PendingIntent.FLAG_IMMUTABLE ) val builder = NotificationCompat.Builder(context, "repair_assign") .setContentTitle("维修已派单") .setContentText("张师傅已接单,预计10分钟内到达") .setSmallIcon(R.drawable.ic_repair) .setContentIntent(pendingIntent) .setAutoCancel(true) .setPriority(NotificationCompat.PRIORITY_HIGH) notificationManager.notify(repairId.hashCode(), builder.build())5. 测试验证与线上问题定位技巧
5.1 使用ADB命令快速复现典型场景
开发阶段需高频验证离线、弱网、定位失败等场景,无需真实环境:
# 模拟断网(关闭所有网络) adb shell settings put global airplane_mode_on 1 adb broadcast -a android.intent.action.AIRPLANE_MODE --ez state true # 模拟弱网(延迟2秒,丢包20%) adb shell tc qdisc add dev wlan0 root netem delay 2000ms loss 20% # 强制设置模拟位置(绕过GPS权限) adb shell settings put secure mock_location 1 adb shell am start -a android.settings.LOCATION_SOURCE_SETTINGS # 清除应用数据(重置测试状态) adb shell pm clear com.school.repair注意:
tc qdisc需root权限,测试机建议刷LineageOS等开源ROM。生产环境禁用mock_location,改用FusedLocationProvider的setMockMode(false)。
5.2 Crash监控与关键路径埋点
集成Firebase Crashlytics捕获原生崩溃,但需补充业务异常埋点:
// SubmitRepairUseCase.kt suspend fun execute(input: SubmitInput): Result<Unit> { return try { // 埋点:开始提交 Analytics.logEvent("repair_submit_start", mapOf("type" to input.type.name)) val remoteId = remoteDataSource.submit(input) localDataSource.deleteDraft(input.id) // 埋点:提交成功 Analytics.logEvent("repair_submit_success", mapOf("remote_id" to remoteId)) Result.success(Unit) } catch (e: NetworkException) { // 埋点:网络失败,进入离线队列 Analytics.logEvent("repair_submit_offline", mapOf("error" to e.message)) localDataSource.saveAsDraft(input) Result.failure(e) } catch (e: Exception) { Analytics.logEvent("repair_submit_error", mapOf("error" to e.javaClass.simpleName)) Result.failure(e) } }在Firebase Console中创建漏斗分析:repair_submit_start→repair_submit_success→repair_status_update,若第二步流失率高,说明上传服务不稳定;若第三步延迟长,需检查维修员端推送逻辑。
5.3 使用Android Studio Profiler定位内存泄漏
学生频繁进出报修页面易引发Fragment泄漏,Profiler可实时检测:
- 运行APP,打开报修表单页
- 在Profiler中点击
Record memory allocations - 快速切换到首页,再切回报修页3次
- 点击
Dump Java heap,搜索SubmitFragment - 若
retained size持续增长,右键Show retained objects,查看mParent引用链
常见泄漏点:CameraX的ImageCapture未在onDestroyView()中unbind()、WorkManager的OneTimeWorkRequest未取消、RxJava未调用dispose()。修复后重新录制对比retained size是否归零。
当学生在实验课间隙拍下实验室投影仪故障照片,从点击“报修”到看到“已提交”提示不超过1.2秒,且断网状态下数据自动暂存——这才是Android校园报修系统该有的样子。
本文还有配套的精品资源,点击获取