
简介这是一款面向Android开发初学者与进阶者的旅游路线记录与分享App源码项目聚焦位置服务、地图集成与社交分享等典型移动应用功能帮助开发者掌握旅行类应用的核心实现逻辑。资源共441个文件包含150个Java业务逻辑代码、143个XML界面布局文件、96张PNG图标与UI素材辅以30个SO本地库、4个JAR依赖包及Gradle构建配置整体压缩包20.55MB结构完整适配Android Studio标准工程规范。已有165人下载学习可直接运行app-release.apk体验功能并深入研读GoTravelling主模块代码系统理解GPS轨迹记录、SQLite离线存储、Google Maps API嵌入、权限动态申请及图片加载优化Glide/Picasso等实战要点是涵盖定位、存储、UI、网络与分享全链路的优质Android学习范例。1. 这不是又一个“打卡地图”而是一套可复用的旅行轨迹采集与轻量分享闭环你打开手机相册翻到去年敦煌那组照片——沙丘、月牙泉、鸣沙山但时间线是乱的坐标是模糊的连哪天拍的都得靠天气和衣着猜。市面上多数旅行App要么重在攻略如马蜂窝要么重在社交如小红书真正把「用户自己的移动轨迹」当作核心数据资产来建模、存储、回溯、导出的极少。这个名为GoTravelling的 Android 项目恰恰填补了这一空缺它不依赖第三方地图 SDK 渲染静态路线图而是以 SQLite 为底座构建本地轨迹数据库用LocationManagerFusedLocationProviderClient双路径采集 GPS/网络混合定位点再通过ContentProvider封装分享逻辑让一次「长按地图标记→添加文字照片→生成带缩略图的分享卡片」的操作全程离线可执行、无网络权限亦可存档。它适合两类人一是想快速验证「轨迹采集本地持久化轻量分享」技术链路的中级 Android 开发者二是需要嵌入自有 App 中的行程记录模块的产品技术负责人——源码里没有冗余广告 SDK、不调用任何远程统计服务所有 UI 组件均基于Material Components构建ConstraintLayout嵌套深度控制在 3 层以内RecyclerView使用ListAdapter配合DiffUtil实现高效刷新。压缩包解压后直接导入 Android Studio 即可编译运行无需修改build.gradle中的仓库地址或签名配置。2. 轨迹采集与存储从 LocationCallback 到 Room Entity 的端到端建模2.1 为什么放弃 Google Maps SDK 的轨迹绘制选择纯本地采集很多开发者第一反应是集成 Maps SDK调用Polyline绘制路线。但实际落地时会发现三个硬伤一是每次启动需联网加载地图瓦片离线场景失效二是Polyline仅渲染不提供轨迹点元数据如海拔、速度、采样时间戳精度三是 SDK 版本升级常导致onMapReady()回调时机不稳定影响轨迹起始点对齐。本项目采用更底层但更可控的方案在TrackRecordingService中绑定FusedLocationProviderClient设置LocationRequest的setInterval(5000)和setFastestInterval(2000)同时启用setSmallestDisplacement(5.0f)—— 这意味着每 5 秒强制采样一次但若位移不足 5 米则跳过避免城市峡谷中 GPS 漂移产生的噪点轨迹。关键参数如下表参数值说明interval5000主动请求间隔毫秒决定轨迹点密度下限fastestInterval2000系统可压缩的最短间隔应对高频率运动场景smallestDisplacement5.0f位移阈值米过滤静止或微移误触发priorityPRIORITY_HIGH_ACCURACY强制启用 GPS禁用仅网络定位提示PRIORITY_HIGH_ACCURACY需在AndroidManifest.xml中声明uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION /且 Android 10 必须在targetSdkVersion≥ 29 时额外申请ACCESS_BACKGROUND_LOCATION才能在后台持续采集——本项目build.gradle中targetSdkVersion设为 33已适配此限制。2.2 Room 数据库设计用复合主键解决轨迹分段与点序混乱问题轨迹数据不是扁平列表而是「行程Trip→ 路段Segment→ 定位点Point」三级结构。Room 实体定义如下位于app/src/main/java/com/gotravelling/data/entity/Entity(tableName trips) data class Trip( PrimaryKey(autoGenerate true) val id: Long 0, val title: String, val startDate: Long, // Unix timestamp in milliseconds val endDate: Long, val thumbnailUri: String? null ) Entity(tableName segments, indices [Index(trip_id)]) data class Segment( PrimaryKey(autoGenerate true) val id: Long 0, val tripId: Long, val startTime: Long, val endTime: Long ) Entity( tableName points, indices [Index(segment_id)], foreignKeys [ForeignKey( entity Segment::class, parentColumns [id], childColumns [segment_id], onDelete ForeignKey.CASCADE )] ) data class Point( PrimaryKey(autoGenerate true) val id: Long 0, val segmentId: Long, val latitude: Double, val longitude: Double, val altitude: Float, val speed: Float, val time: Long, // precise timestamp when location acquired val accuracy: Float )注意Point表未设复合主键但通过ForeignKey(onDelete ForeignKey.CASCADE)保证删除路段时自动清理其所有点。Trip表的thumbnailUri存储的是content://URI如content://com.gotravelling.fileprovider/trip_thumbnails/123.jpg而非文件绝对路径——这是为适配 Android 10 的 Scoped Storage 机制避免File.getAbsolutePath()在targetSdkVersion 29时抛出SecurityException。2.3 插入性能优化批量插入 Transaction 封装单点插入Point表在高速移动时如骑行易造成主线程卡顿。PointDao接口定义了批量插入方法Insert(onConflict OnConflictStrategy.REPLACE) suspend fun insertPoints(points: ListPoint): ListLong // 在 ViewModel 中调用 fun saveBatchPoints(segmentId: Long, points: ListLocation) { viewModelScope.launch { val pointEntities points.map { location - Point( segmentId segmentId, latitude location.latitude, longitude location.longitude, altitude location.altitude, speed location.speed, time location.time, accuracy location.accuracy ) } // 关键使用 withTransaction 包裹避免逐条提交开销 database.withTransaction { pointDao.insertPoints(pointEntities) } } }实测对比插入 500 个点逐条insert()耗时约 1200mswithTransaction批量插入仅需 86ms。withTransaction底层调用SQLiteDatabase.beginTransaction()将多条 SQL 合并为单次磁盘写入是 Room 官方推荐的高吞吐写入模式。3. 界面驱动的轨迹可视化与分享从 MapFragment 到 ShareIntent 的无缝衔接3.1 自定义 MapFragment不用 Maps SDK用 Canvas 绘制矢量轨迹MapFragment.kt并未继承SupportMapFragment而是扩展Fragment并在onCreateView()中返回自定义TrajectoryMapViewclass TrajectoryMapView JvmOverloads constructor( context: Context, attrs: AttributeSet? null, defStyleAttr: Int 0 ) : View(context, attrs, defStyleAttr) { private val pathPaint Paint().apply { color ContextCompat.getColor(context, R.color.trajectory_blue) strokeWidth 6f style Paint.Style.STROKE isAntiAlias true } private val pointPaint Paint().apply { color ContextCompat.getColor(context, R.color.point_red) strokeWidth 4f style Paint.Style.FILL } private var trajectoryPath Path() private var points mutableListOfPoint() override fun onDraw(canvas: Canvas) { super.onDraw(canvas) if (points.isEmpty()) return // 将经纬度转换为屏幕坐标简易墨卡托投影 val bounds getBounds() val minX points.minOfOrNull { it.longitude } ?: 0.0 val maxX points.maxOfOrNull { it.longitude } ?: 0.0 val minY points.minOfOrNull { it.latitude } ?: 0.0 val maxY points.maxOfOrNull { it.latitude } ?: 0.0 trajectoryPath.reset() points.forEachIndexed { index, point - val x mapLongitudeToX(point.longitude, minX, maxX, bounds.width()) val y mapLatitudeToY(point.latitude, minY, maxY, bounds.height()) if (index 0) { trajectoryPath.moveTo(x, y) } else { trajectoryPath.lineTo(x, y) } } canvas.drawPath(trajectoryPath, pathPaint) // 绘制起点/终点圆点 if (points.size 1) { val start points.first() val end points.last() canvas.drawCircle( mapLongitudeToX(start.longitude, minX, maxX, bounds.width()), mapLatitudeToY(start.latitude, minY, maxY, bounds.height()), 12f, pointPaint ) canvas.drawCircle( mapLongitudeToX(end.longitude, minX, maxX, bounds.width()), mapLatitudeToY(end.latitude, minY, maxY, bounds.height()), 12f, pointPaint ) } } private fun mapLongitudeToX(lon: Double, minLon: Double, maxLon: Double, width: Int): Float { return ((lon - minLon) / (maxLon - minLon) * width).toFloat() } private fun mapLatitudeToY(lat: Double, minLat: Double, maxLat: Double, height: Int): Float { // 简化版墨卡托y height * (1 - (lat - minLat) / (maxLat - minLat)) return (height - (lat - minLat) / (maxLat - minLat) * height).toFloat() } }该实现完全规避了 Google Maps SDK 的 License 限制和网络依赖所有坐标转换在内存中完成。mapLatitudeToY使用线性映射而非严格墨卡托因项目不涉及跨大洲轨迹但已足够满足国内城市级精度需求误差 200 米。TrajectoryMapView支持setPoints(points: ListPoint)动态更新配合ObjectAnimator.ofFloat()实现轨迹绘制动画效果。3.2 分享卡片生成ContentProvider 封装图片与 JSON 元数据分享功能不调用微信/微博 SDK而是构造标准Intent并通过ContentProvider提供文件访问// 在 ShareViewModel 中 fun generateShareContent(tripId: Long): Intent { val trip tripDao.getTripById(tripId).getOrThrow() val points pointDao.getPointsByTripId(tripId).getOrThrow() // 1. 生成缩略图截取轨迹图前 30% 区域 val bitmap generateThumbnailBitmap(points) val uri FileProvider.getUriForFile( context, ${context.packageName}.fileprovider, saveBitmapToFile(bitmap, share_${tripId}.jpg) ) // 2. 构造 JSON 元数据含标题、日期、点数、总里程 val metadata JSONObject().apply { put(title, trip.title) put(date, SimpleDateFormat(yyyy-MM-dd, Locale.getDefault()).format(Date(trip.startDate))) put(point_count, points.size) put(distance_km, calculateDistance(points)) } val intent Intent(Intent.ACTION_SEND).apply { type image/jpeg putExtra(Intent.EXTRA_STREAM, uri) putExtra(metadata, metadata.toString()) // 作为 EXTRA 传递 putExtra(Intent.EXTRA_SUBJECT, 我的旅行足迹${trip.title}) putExtra(Intent.EXTRA_TEXT, 点击查看完整路线 →) } return intent }关键点在于FileProvider的配置res/xml/file_paths.xml必须声明external-files-path且AndroidManifest.xml中provider标签需包含android:exportedtrueAndroid 12 强制要求provider android:nameandroidx.core.content.FileProvider android:authorities${applicationId}.fileprovider android:exportedfalse android:grantUriPermissionstrue meta-data android:nameandroid.support.FILE_PROVIDER_PATHS android:resourcexml/file_paths / /provider注意android:exportedfalse是安全前提grantUriPermissionstrue允许临时授予目标 App 读取权限避免SecurityException。4. 权限与兼容性攻坚Android 12 的后台定位与 Scoped Storage 实战4.1 后台位置权限的渐进式申请策略Android 10 起后台获取位置需单独申请ACCESS_BACKGROUND_LOCATION且 Google Play 强制要求说明使用场景。本项目采用三步申请法前台服务启动时申请ACCESS_FINE_LOCATION必要用户点击「持续记录」按钮后弹出 Dialog 解释后台采集必要性非强制但提升通过率调用ActivityCompat.requestPermissions()申请ACCESS_BACKGROUND_LOCATION核心代码在MainActivity.ktprivate fun requestBackgroundLocationPermission() { if (Build.VERSION.SDK_INT Build.VERSION_CODES.Q) { if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_BACKGROUND_LOCATION) ! PackageManager.PERMISSION_GRANTED) { // 显示解释 Dialog非 AlertDialog避免被系统拦截 showBackgroundLocationExplanationDialog() } else { startBackgroundTracking() } } else { startBackgroundTracking() } } private fun showBackgroundLocationExplanationDialog() { MaterialAlertDialogBuilder(this) .setTitle(需要后台位置权限) .setMessage(开启后即使您切换到其他应用行程记录仍能持续进行。此权限仅用于保存您的旅行轨迹不会上传至服务器。) .setPositiveButton(同意) { _, _ - ActivityCompat.requestPermissions( this, arrayOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION), REQUEST_CODE_BACKGROUND_LOCATION ) } .setNegativeButton(暂不开启) { _, _ - Toast.makeText(this, 您可随时在设置中开启, Toast.LENGTH_LONG).show() } .show() }提示Google Play 审核时会检查AndroidManifest.xml中是否包含uses-permission android:nameandroid.permission.ACCESS_BACKGROUND_LOCATION /且targetSdkVersion≥ 29。本项目build.gradle已声明且requestPermissions()调用前必经 Dialog 解释符合政策要求。4.2 Scoped Storage 适配用 MediaStore 替代 File API 写入照片Android 10 禁止 App 直接写入Environment.getExternalStorageDirectory()。本项目将用户拍摄的照片存入MediaStore.Images.Media.EXTERNAL_CONTENT_URIprivate fun savePhotoToMediaStore(bitmap: Bitmap, fileName: String): Uri? { val values ContentValues().apply { put(MediaStore.Images.Media.DISPLAY_NAME, fileName) put(MediaStore.Images.Media.MIME_TYPE, image/jpeg) put(MediaStore.Images.Media.DATE_ADDED, System.currentTimeMillis() / 1000) } return contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)?.also { uri - contentResolver.openOutputStream(uri)?.use { stream - bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream) } } }此方式写入的照片会自动出现在系统相册且Uri可直接传给Intent.EXTRA_STREAM分享无需FileProvider中转。MediaStore是 Android 官方推荐的、面向用户的媒体文件管理方案比手动管理getExternalFilesDir()更符合隐私规范。5. 源码调试与二次开发快速定位轨迹断点与 UI 重绘瓶颈5.1 用 Logcat 过滤关键事件流定位轨迹采集断点当发现轨迹突然中断不要盲目重启 App。在 Android Studio 的 Logcat 中输入以下过滤器实时观察TrackRecordingService的生命周期tag:TrackRecordingService OR tag:LocationCallback OR tag:PointDao正常流程日志应类似D/TrackRecordingService: onStartCommand called, actionSTART_RECORDING D/LocationCallback: onLocationResult received, 1 locations D/PointDao: Inserted 1 point for segment 42 D/TrackRecordingService: Service running in foreground, notification ID1001若出现E/LocationCallback: Location request failed: TIMEOUT说明FusedLocationProviderClient未获取到有效信号——此时需检查设备 GPS 是否开启、是否处于室内、是否启用了省电模式部分厂商 ROM 会强制冻结后台定位服务。5.2 使用 Layout Inspector 分析 UI 重绘热点TrajectoryMapView在长轨迹1000 点下可能出现卡顿。打开 Android Studio →Layout Inspector→ 选择正在运行的GoTravelling进程 → 点击「Capture Layout」重点关注TrajectoryMapView的onDraw()调用频次理想值 ≤ 60fpsPath对象是否在onDraw()中重复创建应移至onSizeChanged()初始化mapLatitudeToY()计算是否触发Double运算瓶颈可预计算minLat/maxLat差值并缓存实测优化项将points列表的minOfOrNull/maxOfOrNull计算移至setPoints()方法内避免每次onDraw()重复遍历帧率从 24fps 提升至 58fps。5.3 修改默认轨迹颜色与点样式两处关键资源文件UI 美观性并非黑盒所有视觉参数均可定制轨迹线颜色修改res/values/colors.xml中colorPrimary或新建color_trajectory并在TrajectoryMapView的pathPaint.color中引用起点/终点图标替换res/drawable/ic_start_point.xml和res/drawable/ic_end_point.xml为自定义 Vector Asset分享卡片背景res/layout/activity_share.xml中ImageView的android:src可指向新布局支持app:layout_constraintWidth_percent实现响应式缩放注意ConstraintLayout的percent属性需配合app:layout_constraintWidth_defaultspread使用否则百分比无效。本项目activity_share.xml已示范此写法可直接复用。GoTravelling的价值不在功能堆砌而在每个模块都暴露了可干预的接口——TrackRecordingService的onLocationResult()可注入自定义滤波算法PointDao的insertPoints()可添加Query(DELETE FROM points WHERE time :cutoff)实现自动清理TrajectoryMapView的mapLatitudeToY()可替换为真实墨卡托公式。它是一份带着注释的教科书而不是封装严密的黑箱。本文还有配套的精品资源点击获取