简介:基于Android Studio开发的旅游记录与分享类安卓应用完整源码,面向安卓初学者、毕业设计学生及移动应用开发爱好者。项目覆盖路线规划、旅游轨迹记录、社交分享等核心功能模块,涉及地图接口集成、卫星定位追踪、本地数据库存储、网络请求框架以及模型-视图-主持者等架构设计技术要点,可支撑课程设计、毕业设计或相关实训项目快速落地。资源包共432个文件,压缩包约19.17MB,主要包含150个Java源码文件、143个XML布局与配置文档、88张PNG图片资源、30个SO动态库文件,以及Gradle构建脚本和可直接安装的APK,目录结构清晰,贯通数据层、业务层与界面层。目前已有2877人学习下载。借助该源码可系统了解定位服务接入、动态权限申请、异步任务处理与性能优化等真实开发实践,并在此基础上二次开发,快速搭建具备轨迹记录与社交分享能力的移动应用。
1. 旅游记录与分享 APP:从“拍了一堆照片”到“攒下一条完整路线”
出门玩一趟,手机里几百张照片、几十段视频,回来想跟朋友讲讲“我这次是怎么走的”,往往只能翻相册翻到手指酸。我做过几个类似的工具后,最大的感触是:旅游记录这件事,痛点不在“拍”,而在“串”——把时间、地点、轨迹、照片串成一条能回放、能分享的路线。这个基于 Android Studio 开发的旅游记录与分享 APP,解决的就是这件事:打开 APP 自动记录 GPS 轨迹,沿途拍照自动绑定坐标,走完全程生成一条带缩略图、带里程、带耗时统计的路线,一键分享成图片或链接。适合两类人:一是正在学 Android 开发、想找一个完整可运行项目练手的学生,二是想给自己或小团队做一款轻量旅行工具的独立开发者。下面我按自己惯用的实现路径,把这个 APP 从数据设计、核心代码到踩坑记录完整拆开讲。
2. 先把数据模型定死:路线记录与分享的“地基”怎么搭
2.1 核心实体拆解:路线、轨迹点、媒体文件、分享快照
任何记录类 APP,第一步不是写界面,而是想清楚要存什么数据。旅游路线记录 APP 的核心实体就四个:路线(Route)、轨迹点(TrackPoint)、媒体文件(MediaItem)、分享快照(ShareSnapshot)。它们之间的关系很简单:一条路线包含多个轨迹点,一个轨迹点可以挂零到多张照片,分享快照是路线的只读投影。
我一般会把轨迹点和媒体分开存,而不是把照片路径直接塞进轨迹点表里。原因有二:一是 GPS 采样频率通常是每秒一次,而拍照只有几十张,混在一起会让轨迹点表膨胀;二是轨迹点需要频繁追加和更新,媒体文件只需要插入和读取,分开后缓存策略更好做。在 Room 数据库里,这两个表的外键都指向 routeId,查询时用一次 JOIN 就能组装出完整路线。
再说轨迹点本身。实际记录时,GPS 信号会有抖动,原地不动也会漂出几米。所以我建议 TrackPoint 里除了 lat、lng、timestamp 这三个必填字段,再加一个 accuracy 字段,记录当时 GPS 的定位精度(米)。回放路线时,精度大于 50 米的点可以直接丢弃,这是最简单也最有效的轨迹平滑手段。路线表里则存总里程、总时长、起点终点地址、创建时间这些汇总字段,避免每次展示列表都要重新遍历几千个点去算。
@Entity(tableName = "route") data class Route( @PrimaryKey val id: String, val name: String, val startTime: Long, val endTime: Long, val totalDistanceMeters: Float, val totalDurationSeconds: Long, val startAddress: String?, val endAddress: String? ) @Entity(tableName = "track_point") data class TrackPoint( @PrimaryKey val id: String, val routeId: String, val lat: Double, val lng: Double, val accuracy: Float, val timestamp: Long )这里有一个关键的取舍:路线 id 我用 String 而不是自增 Long。原因很简单,分享功能需要把路线传给别人,String 可以用 UUID 生成,别人拿到后可以直接用这个 id 去服务端拉数据,不会撞号,也不暴露你的总路线数。Room 对 String 主键的性能没有影响,放心用。
2.2 数据流设计:记录态、回放态、分享态三态分离
这 APP 的数据流,我建议按三个状态来组织:记录中、回放中、分享中。记录态是高频写入,轨迹点每秒一个,必须走内存队列批量落库;回放态是只读展示,把轨迹点读出来画到地图上;分享态是把路线和媒体打包导出。三个状态的数据流向不能混,否则会出现“回放时还在往数据库写点”这种低级问题。
记录态的写入我一般这样实现:LocationManager 回调先写入一个 ConcurrentLinkedQueue,一个后台线程每 5 秒批量把队列里的点刷入 Room。不要每次回调直接 insert,因为 Room 的写事务有开销,高频写入会卡 UI 线程。批量 500 条一次 commit,实测对大多数中端机都很稳。
回放态的关键是不要把整条路线的轨迹点一次性加载进内存。一条 10 公里的徒步路线,每秒一个点就是 2 万个点,全部加载不仅慢,而且地图绘制时卡顿。做法是分页加载,每 5 秒的轨迹点合并成一个 Polyline 段,地图上只保留当前可见区域的段。具体代码在下一章讲地图绘制时给出。
分享态要解决的是数据自包含问题。分享出去的链接或文件,不能依赖接收方也装了这个 APP。所以分享快照里存的是路线摘要信息(起点终点、里程、时长、封面图缩略图),加上一串加密后的轨迹数据。接收方打开链接,先看到摘要,点击加载完整轨迹时,再从服务端拉 JSON。这个 JSON 的结构我放在 2.3 节讲。
2.3 分享用的 JSON 结构:体积、精度、兼容性的三方博弈
分享数据的序列化格式,我对比过 JSON 和 Protobuf,最终选了 JSON。原因很务实:Protobuf 压缩率高,但接收方解析需要引入库,在网页端和微信里打开链接时没法直接用。JSON 虽然体积大,但在服务端、Web、App 三端都能直接解析,配合 Gzip 压缩,一条 2000 个点的路线压缩后也就 20KB 左右,完全可以接受。
JSON 结构长这样:
{ "routeId": "uuid-string", "name": "西湖晨跑", "startTime": 1719561600000, "endTime": 1719565200000, "distanceMeters": 5231.8, "durationSeconds": 3600, "points": [ {"lat": 30.2456, "lng": 120.1523, "t": 1719561600000, "a": 8.2}, {"lat": 30.2457, "lng": 120.1525, "t": 1719561601000, "a": 7.8} ], "media": [ {"lat": 30.2461, "lng": 120.1530, "url": "https://cdn.example.com/photo1.jpg", "thumb": "https://cdn.example.com/thumb1.jpg", "t": 1719561900000} ] }字段设计上有一个容易被忽略的点:轨迹点的坐标我保留了 4 位小数,对应的精度约 10 米,对展示路线足够,但体积比 6 位小数少三分之一。而照片的 url 和 thumb 分开存,接收方在弱网下先看缩略图,点击才加载原图。别忘了在 points 里冗余每点的精度字段 a,分享出去后,接收方如果要做轨迹平滑,没有这个字段就只能盲目丢点。
2.4 数据库版本迁移与源码组织:为后维护留好余地
数据库版本迁移这事,很多新手项目完全没做,表结构一改就卸载重装。旅游记录 APP 的路线数据是用户的核心资产,绝不能因为升级就丢。Room 里我用 Migration 逐版本迁移,每次加表或加字段都写一个 Migration 对象注册到数据库构建器里。其中一个血泪教训是:新加的字段必须给默认值,否则迁移后老数据读出来会直接崩溃。
源码组织上,我习惯按功能分包而不是按层分包。就是说不要搞 com.example.model、com.example.adapter、com.example.util 这种包结构,而是按 record、share、map、media 这样分。一个功能的所有类都放在同一个包下,改需求时只需要在这个包内动,不用跨五个包找人。这个项目规模不大,四到六个包足够,多了反而绕。
3. 路线记录的核心实现:定位采集、轨迹绘制、图片绑定的最小可运行方案
3.1 定位采集:前台服务 + 高精度定位,别用错了 LocationClient
路线记录的第一个核心是定位采集。这 APP 必须在屏幕关闭、APP 退到后台时仍然继续记录轨迹,否则用户把手机放兜里走两小时,轨迹断了,整个记录就废了。最常见做法是启动一个前台服务,绑定 LocationManager 或高德百度定位 SDK 来持续获取位置。用前台服务是因为 Android 8 之后后台定位受限,普通 Service 在后台存活不了几分钟就会被系统回收。
我是直接用 Android 系统 LocationManager 实现的,没有接第三方定位 SDK。原因有两:一是系统定位在室外开阔地带的精度已经足够(10 米内),二是少一个 SDK 就少一份体积和隐私合规负担。如果你需要室内定位或逆地理编码,再考虑接高德或百度,但核心轨迹记录用系统定位完全够。
定位参数上我建议这样设:优先级设 PRIORITY_HIGH_ACCURACY,最小时间间隔设 2000 毫秒,最小距离间隔设 0 米。时间间隔不要设 1000 毫秒,因为很多手机的 GPS 芯片实际刷新率就到不了每秒一次,设 1 秒反而会让系统频繁唤醒,耗电翻倍,精度却没什么提升。距离间隔设 0 是为了不错过转弯等关键点,靠时间间隔来控制采样密度就够。
前台服务的 notification 是绕不开的“丑点”。Android 要求前台服务必须显示通知,而且不能手动划掉。我一般把通知内容做成“本次记录 2 小时 15 分 / 里程 8.3 公里”这种实时状态,用户至少能看到有价值的信息,而不是一个干巴巴的“正在运行”。更新通知内容的频率设在 30 秒一次,频繁更新通知也是一个耗电点。
3.2 轨迹绘制:高性能 Polyline 与动态加载策略
轨迹绘制直接决定 APP 的体验。如果用户在地图上划动时轨迹线一顿一顿,或者放大缩小卡死,那功能再完整也没人用。核心做法是:不要一次性把几千个坐标点丢给地图控件去画。
我的做法分三步。第一步,把轨迹点按时间切片,每 200 个点切一段,每段生成一个 Polyline 对象。第二步,监听地图的可见区域变化,当某段 Polyline 的包围盒和当前可见区域相交时才把它加到地图上,否则移除。第三步,Polyline 的宽度设成 dp 而不是 px,保证不同密度的屏幕上看起来粗细一致。
fun renderTrackSegment(map: GoogleMap, points: List<TrackPoint>, isVisible: (LatLngBounds) -> Boolean) { val segmentBounds = LatLngBounds.builder() .include(points.first().toLatLng()) .include(points.last().toLatLng()) .build() if (!isVisible(segmentBounds)) return val polylineOptions = PolylineOptions() .addAll(points.map { it.toLatLng() }) .width(dpToPx(6f)) .color(Color.parseColor("#2E7D32")) .geodesic(true) map.addPolyline(polylineOptions) }这里的性能关键在于 isVisible 判断。我维护了一个 HashMap 存每段的包围盒,地图 onCameraIdle 回调时遍历所有段,只把包围盒与当前可见区域有交集的段 addPolyline,其余的从地图移除。每次相机移动只做几百次矩形相交判断,耗时可以忽略,但绘制时的点数量从 2 万降到了几百,流畅度完全是两回事。
还有一个细节:PolylineOptions 的 geodesic 参数设为 true,这样长距离路线会沿着地球曲率画弧线,而不是画直线。在 100 公里以上的自驾路线里,这个差别肉眼看得很明显,不设的话直线会穿过湖泊和山体,观感很业余。
3.3 照片绑定:拍照时顺手写一条 MediaItem 记录
旅游记录 APP 的照片功能,核心不是拍照,而是“把照片和路线关联起来”。用户打开相机拍照,拿到的是一张图片,你需要同时记录拍摄时的经纬度和时间,然后作为 MediaItem 存入数据库。这步的原理简单,但有个容易忽视的点:Camera 返回的 bitmap 是经过旋转的,而 EXIF 信息里还藏着原始方向,如果你不读 EXIF,照片在别的设备上看到的方向可能就是横的。
我用 CameraX 的 ImageCapture 来实现拍照,在 onCaptureSuccess 里拿到 ImageProxy,转成 Bitmap 后保存到应用私有目录,再把路径、经纬度、时间写入 MediaItem 表。之所以存私有目录而不是公共相册,是因为公共相册的图片会被其他 APP 扫描到,用户删除相册照片时路径失效,数据库里就成了死链。私有目录配合 MediaStore 按需导出,是更适合这个场景的方案。
imageCapture.takePicture(ContextCompat.getMainExecutor(this), object : ImageCapture.OnImageCapturedCallback() { override fun onCaptureSuccess(image: ImageProxy) { val bitmap = image.toBitmap() val file = File(filesDir, "media/${System.currentTimeMillis()}.jpg") file.parentFile?.mkdirs() file.outputStream().use { fos -> bitmap.compress(Bitmap.CompressFormat.JPEG, 85, fos) } mediaDao.insert(MediaItem( id = UUID.randomUUID().toString(), routeId = currentRouteId, lat = lastLocation.latitude, lng = lastLocation.longitude, filePath = file.absolutePath, timestamp = System.currentTimeMillis() )) image.close() } })这段代码里最后那行 image.close() 特别重要。ImageProxy 不关闭的话,CameraX 会认为相机仍被占用,下一次拍照就会黑屏或直接报错。我见过好几个项目在这翻车,现象是“拍第二张照片时 APP 卡死”,原因就是忘了 close。JPEG 压缩率设 85 是我试出来的平衡点,照片在手机上看不出区别,但体积能少 40% 左右。
3.4 生成分享卡片:用 Canvas 把路线图、统计信息画成一张图
分享是这 APP 的传播核心,分享的内容越好看,用户越愿意发。我做了两种分享形态:一种是一张图片,可以直接发给微信好友或发朋友圈;另一种是一段链接,点开能在网页上看完整路线回放。图片分享卡的实现不复杂,本质是拿一张地图截图,再把文字统计画上去。地图截图用 GoogleMap 的 Snapshot 方法或者高德的 getMapScreenShot,拿到 Bitmap 后用 Canvas 叠加文字。
fun generateShareCard(route: Route, mapSnapshot: Bitmap): Bitmap { val width = 1080 val height = 1620 // 3:4 比例,朋友圈和微博都比较友好 val card = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) val canvas = Canvas(card) canvas.drawColor(Color.WHITE) val mapRect = Rect(0, 0, width, (height * 0.6f).toInt()) canvas.drawBitmap(mapSnapshot, null, mapRect, null) val paint = Paint().apply { color = Color.DARK_GRAY textSize = spToPx(24f) isAntiAlias = true textAlign = Paint.Align.LEFT } canvas.drawText("总里程 ${formatDistance(route.totalDistanceMeters)}", 80f, (height * 0.68f).toFloat(), paint) canvas.drawText("总耗时 ${formatDuration(route.totalDurationSeconds)}", 80f, (height * 0.73f).toFloat(), paint) canvas.drawText(route.startAddress ?: "起点", 80f, (height * 0.80f).toFloat(), paint) canvas.drawText(route.endAddress ?: "终点", 80f, (height * 0.86f).toFloat(), paint) return card }Canvas 绘制分享卡片最容易踩的坑是文字密度单位。textSize 如果直接用 px,在不同分辨率的手机上生成出来的卡片文字大小会不一样,卡片在高端机上显得空旷,低端机上挤成一团。上面代码里的 spToPx 是拿 Resources.getDisplayMetrics().scaledDensity 换算的,这样才能保证生成的图片里文字相对大小一致。
4. Android 旅游 APP 开发的五个必踩坑:从分区存储到定位权限,每条都有后悔药
4.1 照片存不进相册:Android 10 分区存储的兼容性黑洞
现象:APP 在 Android 9 上运行正常,拍照后能存进相册;同一套代码放到 Android 10 及以上,拍照后 APP 不报错,但相册里看不到新照片,重启 APP 后甚至自己都读不到之前拍的图。
原因:Android 10 开始强制分区存储,APP 只能无权限访问自己的私有目录,访问公共目录必须走 MediaStore。直接用 File 路径往 DCIM 或 Pictures 目录写,在老系统上可以用 WRITE_EXTERNAL_STORAGE 权限绕过,新系统上权限被弱化,写入不报错但文件被隔离,相册扫描不到。
解决:统一把图片写到 APP 私有目录,需要分享或导出时再通过 FileProvider 或 MediaStore 插入公共相册。FileProvider 的 paths.xml 要正确配置,漏配会导致运行时 crash 或 500 错误。MediaStore 插入时记得带上 RELATIVE_PATH 和 IS_PENDING,IS_PENDING 置 true 避免相册扫描到半写入的文件,写完再置 false 通知系统扫描。
4.2 屏幕一关定位就断:后台定位限制的一线生机
现象:APP 在前台时轨迹记录完美,按下电源键熄屏或者切到别的 APP,等再回来一看,轨迹只有开头一条线,中间全是断的。
原因:Android 8 之后,应用在后台时定位频率会被大幅降低,Android 10 之后更加严格,后台定位需要单独的权限声明,且系统对前台服务的定位访问也有调度限制。普通 Service 在后台活不过几分钟,LocationManager 回调频率会突然从每秒一次掉到几分钟一次,甚至完全不回调。
解决:前台服务是必须的,而且要在 manifest 里声明 foregroundServiceType="location",并在运行时动态申请 ACCESS_BACKGROUND_LOCATION 权限。这两个缺一个,系统会直接限制定位回调。另外在服务里要持有独立的唤醒锁(PARTIAL_WAKE_LOCK),防止 CPU 休眠导致 GPS 模块停止输出。注意唤醒锁使用后必须释放,否则耗电异常会被用户发现并卸载。
4.3 模拟器上不报错但拿不到定位:测试环境的伪装问题
现象:用 Android Studio 自带的模拟器测试 APP,地图正常显示,但轨迹一直记录不了,定位回调完全不触发,或者返回的经纬度是 0,0。
原因:模拟器默认的 GPS 信号是通过模拟器扩展面板手动发送的,系统 API 不会自动返回任何位置。即使你在 Extended Controls 里设置了坐标,某些模拟器版本对 LocationManager 的 getLastKnownLocation 支持仍有兼容问题。
解决:测试路线的记录和回放,最可靠的方式是使用真实手机,开启开发者选项中的“模拟位置信息”或直接用 ADB 命令投递 mock 定位。如果要自动化测试,用下面这个 ADB 命令投递定位点,注意这是 gps 类型,精度 accuracy 字段要带上,否则部分手机判断是无效定位会不予采用。我自己的习惯是:每次出门试新版之前,先用 mock 定位把 2 小时的轨迹数据灌进数据库,验证回放逻辑,再上真机。
4.4 数据库升级后打开闪退:Room 迁移的默认值陷阱
现象:安装了 1.0 版本的老用户,覆盖安装 2.0 版本后首次打开 APP 直接闪退,日志显示 SQLiteConstraintException,提示 NOT NULL constraint failed。
原因:新版本在 route 表里加了 startAddress 字段,并且没有给默认值,而老版本的数据库里 route 表已经存在,迁移时 ALTER TABLE ADD COLUMN 添加的字段默认是 NULL,但实体类里声明的类型是非空的,插入或查询时触发了约束冲突。
解决:Room 的 Migration 里添加字段时,SQL 语句要写成 ADD COLUMN startAddress TEXT DEFAULT '未知',不要写成无默认值的纯 ADD COLUMN。实体类里对应字段也要给默认值。一个更稳妥的土办法是:迁移后对老数据再做一次 UPDATE 回填默认值。如果是小范围自用,手动卸载重装确实是最快的方案,但如果已经有人用你的 APP 记录了路线,卸载重装等于把用户的资产清零,后者影响更大。
4.5 路线太长导致 App 卡死:内存总是撑不住
现象:进行了一次长达 3 小时的自驾,记录了几千个轨迹点。进入路线详情页,地图直接空白,或者滑动时明显掉帧,点返回时 ANR 弹窗提示 APP 无响应。
原因:轨迹点全部一次性加载进内存且一次性 addPolyline。3000 个点对地图引擎来说是很大的负担,每个 Polyline 都有自己的绘制开销,显卡和 CPU 都扛不住。
解决:按章节 3.2 的做法,把轨迹点按时间切片分段绘制,每段 200 个点。不要点“加载全部”,而是按可见区域动态加载。另外,加载轨迹的线程不要用于 UI 线程,用协程或者线程池,读取数据库那几十毫秒虽然不长,但积累起来会让用户感觉到明显的顿挫。如果不想搞太复杂,最简单的优化是保存一张路线的缩略轨迹图,详情页只显示缩略图,点击“查看原轨迹”才加载完整点集。
5. 让旅游 APP 更好用的进阶技巧:轨迹清理、卡片分享、合规审核
5.1 轨迹数据的生命周期管理
轨迹点的大量累积是必然的,如果你在 APP 里没有做清理策略,用户的手机存储会被逐步吃满。我一般会给每个用户做一个存储上限策略,比如 50 条完整路线或者 500 MB 媒体文件,超出后对最早的一个月数据进行降级清理。降级清理不是直接删除,而是将轨迹点聚合成简化版数据:每 10 个轨迹点保留一个关键点,并删掉原始高精度坐标。这样用户仍能查看路线概览,但缩略图数据量只有原来的十分之一。
用户对“数据被清理”很敏感,所以执行前必须明确弹窗告知,并提供重建完整数据的入口。一个折中方案是:完整数据先从设备移到云端,设备上只留简化版,这样用户没有数据损失感,只是加载完整版时需要联网。这个小细节能帮你避免大批用户的卸载。
5.2 分享卡片的视觉设计:让用户愿意发的关键
分享卡片功能做完并不意味着用户会用它。我发现真正愿意分享的,是那些打开图片一眼能看出“走了多远、拍了多少照片、主题是什么”的卡片。所以在生成分享卡时,三层信息是必要的:封面地图截图(显示完整轨迹线)、统计信息(里程、时长、照片数)、一句话描述(用户手动输入或根据起点终点生成)。地图截图区域的轨迹线要加粗描边,和背景色拉开对比度。
分享卡的 Canvas 绘制还有一个性能坑:如果地图尺寸很大,Snapshot 生成图片可能超过 4096 像素的纹理上限,导致部分机型显示黑屏。我在生成卡片前会判断地图 Bitmap 尺寸,超过 2048 就先用 Matrix 缩放,再做 Canvas 合成。
5.3 UGC 内容的安全审核
用户生成内容包含了轨迹和照片,其中照片可能涉及他人隐私,轨迹可能暴露家庭住址、工作单位。上线前记得接入内容审核能力,最简单的方式是在上传照片时调用云服务的内容安全接口,对涉政、违禁、色情内容打标拦截。这一块代码不多,但能有效规避运营风险,建议认真对待。
轨迹的敏感信息处理上,可以提供一个“隐藏家附近 500 米”的功能,分享时抹掉起终点附近 500 米内的轨迹点。这个功能我在多个 APP 里都实现过,代码是一个很简单的循环过滤,但对隐私敏感的用户来说价值极高。
5.4 我的一个习惯:每次出门前先用 Mock 定位走一遍全流程
最后说一个让我避免了很多次翻车的习惯。旅游 APP 的定位、记录、分享链路,依赖真实 GPS 信号、网络状态、系统权限调度,这些在模拟器上都验证不充分。我每次改完定位相关的代码,不会直接出门实测,而是先用 Android Studio 的 Location 模拟面板注入一条预设轨迹,把整个流程走一遍:开始记录、切后台、锁屏、解锁回来、结束记录、生成分享卡片、分享到微信。确认这些链路没问题,才敢带着真机出门做运动测试。这个习惯帮我堵住过好几回“分享卡片生成时照片路径为空”的崩溃。
如果你打算在这个方向做深入,我建议你不要急于自己从零写,找一个开源的完整项目作为起点。自己的项目负责把控需求和界面细节,把精力聚焦在最核心的功能迭代上。定位和地图这两个环节的水很深,先站在已有的肩膀上做出第一个可体验的版本,比什么都重要。希望这篇笔记帮你在旅游记录与分享 APP 的开发路上少走几步弯路。
本文还有配套的精品资源,点击获取