做了几年的Java后端,又折腾过一阵子Android端,一直想找个能把两边串起来的完整项目练手。趁着接了个旅游导航App的需求,我就自己从零搭了一套“Spring Boot后端 + Android原生客户端”的华蓥山旅游导航系统。项目整体不算大,但涉及地图集成、POI检索、路线规划、用户收藏与点评,前后端联调的打法和坑位都挺典型,特别适合想完整走一遍“需求分析–后端接口–安卓端实现–联调调试–交付打包”这套流程的开发者参考。这篇就把我的设计思路、核心代码拆解、以及踩过的坑完整分享一下。
1. 项目立项:华蓥山旅游导航系统到底要解决什么
1.1 用户痛点和需求分析
华蓥山这类山地景区,最麻烦的不是缺少景点,而是景点分散、路况不熟、游客常常在岔路口不知道该往哪走。普通的纸质地图和景区路牌,到了山里基本靠缘分,游客打开手机地图App,能看到大方向,但拿不到景区内部的步道、索道、休息点这类详细路线。这就是我做这套系统的直接动机:给游客提供一个景区内部的专用导航工具,既能看景点列表和详情,又能一键规划游览路线,还能存收藏、写点评。
我把需求拆成了三个层次。第一层是“看”:浏览景区介绍、景点详情、开放时间、门票价格。第二层是“走”:地图展示、当前位置定位、目标景点路线规划。第三层是“记”:注册登录、收藏景点、发表游览点评。第一层是基础信息展示,第二层是核心功能,第三层是增加用户粘性的互动能力。明确了这三层,开发范围就清晰了。
1.2 技术选型:为什么是Spring Boot和Android原生
技术选型这块,我其实纠结过一阵子。最开始考虑过后端用Node.js或者Python Flask,但最终定了Spring Boot。原因很现实:Spring Boot对后端开发者太友好了,内置Tomcat、自动配置、开箱即用,配合MyBatis-Plus做数据操作,开发效率很高。而且这类旅游系统的接口无外乎增删改查,Spring Boot的生态在CRUD场景下非常成熟,资料多、坑少,遇到问题几乎都能搜到现成方案。
Android端则选了原生开发。有人可能会问,为什么不考虑Flutter或者UniApp?我的判断是,这个项目最核心的模块是地图导航,地图SDK在原生Android上的表现更稳定。无论是高德还是百度,原生SDK的定位精度、地图渲染、导航组件都比跨平台框架成熟得多。我用的是高德地图SDK,后文会详细展开。而且作为一个Spring Boot后端为主的开发者,借着这个项目把Android原生开发补上,技术栈的完整性也更好了。
2. 整体架构设计:前后端分离的旅游导航系统
2.1 后端模块划分与数据表设计
后端我用的是经典的三层结构:Controller接收请求,Service处理业务逻辑,Mapper操作数据库。模块拆成五个:用户模块、景点模块、路线模块、收藏模块、评论模块。项目结构大概这样:
src/main/java/com/huayingshan/ ├── controller/ # 控制层,接收HTTP请求 ├── service/ # 业务逻辑层 ├── mapper/ # 数据访问层(MyBatis-Plus) ├── entity/ # 实体类 ├── config/ # 配置类(CORS、拦截器、Redis等) ├── common/ # 通用返回结果、异常处理、工具类 └── HuayingshanApplication.java数据库我设计了六张核心表。用户表存账号密码和昵称头像;景点表存景点名称、位置经纬度、介绍、开放时间、图片URL;路线表存路线名称、起点终点、途经点、长度和预计耗时;收藏表是用户和景点的关联表;评论表存用户对景点的评分和文字内容;还有一张管理员表。下面是我认为设计阶段最需要注意的一张建表语句,景点表:
CREATE TABLE `scenic_spot` ( `id` bigint NOT NULL AUTO_INCREMENT, `name` varchar(100) NOT NULL COMMENT '景点名称', `description` text COMMENT '景点详细介绍', `latitude` double NOT NULL COMMENT '纬度', `longitude` double NOT NULL COMMENT '经度', `open_time` varchar(50) DEFAULT NULL COMMENT '开放时间', `ticket_price` decimal(10,2) DEFAULT NULL COMMENT '门票价格', `cover_url` varchar(500) DEFAULT NULL COMMENT '封面图片地址', `category` varchar(20) DEFAULT NULL COMMENT '景点分类,如:自然风光/人文古迹', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='景点信息表';经纬度字段我特意用了double而不是decimal,因为高德地图返回的坐标就是双精度浮点数,直接用double既能保证精度,又不用每次类型转换。景点分类字段也要预留,后面做筛选功能会用到。
2.2 Android端分层架构和地图SDK选择
Android端我采用了MVVM架构,配合Retrofit + OkHttp + ViewModel + LiveData这一套。以前写过纯MVP的项目,Activity里堆满接口回调,代码一多就很难维护。MVVM下,Activity只负责渲染和交互,业务逻辑放在ViewModel里,网络请求状态通过LiveData通知UI更新,逻辑清晰得多。
地图SDK的选择上,我对比过高德和百度。高德的优势是路线规划API接口丰富,导航组件直接可用,而且开发者文档写得好;百度的优势是地图数据在某些地区更新更及时。考虑到华蓥山景区的路线数据需要自定义编辑,高德的“自定义地图”和“路线规划”组合更适合这个场景,最终定了高德。
Android端的模块划分包括:地图模块(MapActivity)、景点模块(SpotListActivity、SpotDetailActivity)、路线模块(RoutePlanActivity)、导航模块(NavigationActivity)、用户模块(LoginActivity、RegisterActivity、ProfileActivity)。每个模块对应一个或多个ViewModel,数据来源统一走Repository层。
3. 后端核心实现:接口设计与业务关键点
3.1 用户模块和JWT鉴权
用户模块看着简单,实际踩坑不少。注册登录我采用了JWT做无状态鉴权,Spring Boot后端生成Token,Android端保存到SharedPreferences,每次请求自动带上。用JWT的好处是后端不需要维护Session,对移动端天然友好,也方便以后扩展Web端管理后台。
密码存储这块必须强调,别用明文,也别用MD5。我用的BCrypt加密,Spring Security框架里自带,加盐之后密码即便泄露也无法直接还原。用户表里存的是BCrypt加密后的密文,登录时把用户输入的密码用BCrypt的matches方法校验。这个习惯应该成为所有后端开发的肌肉记忆。
登录接口的设计如下:
@PostMapping("/api/user/login") public Result<UserVO> login(@RequestBody LoginDTO dto) { User user = userService.login(dto.getUsername(), dto.getPassword()); if (user == null) { return Result.error("用户名或密码错误"); } String token = jwtUtil.generateToken(user.getId(), user.getUsername()); UserVO vo = new UserVO(); BeanUtils.copyProperties(user, vo); vo.setToken(token); return Result.success(vo); }返回给前端的UserVO里包含用户基本信息和Token,前端拿到Token后存在本地。拦截器里校验请求头中的Authorization字段,如果Token无效或者过期,直接返回401状态码。这里有个细节,CORS跨域配置要和拦截器配合好,不然预检请求会被拦截器拦掉。
3.2 景点和路线相关接口实现
景点接口是纯查询场景,但有个性能问题值得单独说。景区内的景点数量可能不多,但列表页需要展示封面图、简介、评分、距离等多维信息,如果每个字段都查一遍数据库,页面的响应时间会很难看。我的做法是列表接口只查核心字段,详情接口再返回完整的景点介绍。列表查询用MyBatis-Plus的LambdaQueryWrapper构建条件,分页用内置的分页插件,不需要手写SQL。
路线规划接口是业务核心。刚开始我设计了两种能力:一种是预设线路推荐,比如“经典一日游线路”,后台维护好途经点,前端直接展示;另一种是用户自定义路线,前端把起点和终点传给后端,后端调用高德地图的路径规划API,把返回的路线坐标点数组存下来。实测下来,预设线路更适合景区场景,因为景区内部路线固定,自定义路径规划的通用性反而不如固定线路好用。所以我把预设线路作为主推,自定义规划作为补充。
预设路线的表结构和接口设计:
@GetMapping("/api/route/detail/{routeId}") public Result<RouteDetailVO> getRouteDetail(@PathVariable Long routeId) { RouteDetailVO vo = routeService.getRouteDetail(routeId); return Result.success(vo); }RouteDetailVO里除了路线本身的信息,还包含该路线途径的景点详情列表,这样前端一次请求就能把路线和沿途景点展示完整。后端在Service层做了两表关联查询,先查路线表,再根据route_spot关联表查景点ID,批量查出景点信息。
3.3 缓存、搜索和统一异常处理
景区数据的特点是“读多写少”,非常契合Redis缓存的适用场景。我在景点列表和路线详情两个接口上加了Redis缓存,缓存key为“spot:list:page:{pageNum}”和“route:detail:{routeId}”,过期时间设置为30分钟。这样同一时间大量游客刷列表时,压力不会直接打到MySQL上。
搜索功能我用的MySQL的LIKE模糊查询。有朋友建议上Elasticsearch,但华蓥山这个场景数据量有限,ES属于杀鸡用牛刀。用LIKE查询景点名称和介绍,配合MySQL索引,完全够用。搜“天池”能搜到“华蓥天池”,“溶洞”能搜到“奇峰溶洞”,效果达标。
统一异常处理和统一返回结果也很重要。我定义了一个通用Result类,code为200代表成功,其他code对应不同错误场景。全局异常处理器通过@RestControllerAdvice捕获业务异常和系统异常,避免出现500错误时直接把Tomcat的默认错误页返回给Android端。下面是统一返回结构的JSON格式:
{ "code": 200, "message": "success", "data": { "id": 1, "name": "华蓥山天池", "latitude": 30.231, "longitude": 106.782 } }这个格式前后端都要遵守。我在实际开发中看到太多项目因为返回格式不统一,导致前端解析代码写一大堆if判断来处理不同数据结构,非常浪费精力。
4. Android端实现:地图导航主流程与交互细节
4.1 地图初始化和定位功能
Android端最核心的模块就是地图。高德SDK接入流程不算复杂,但要注意几个细节。第一步是在高德开放平台申请Key,这一步必须把项目的包名和发布版SHA1值填对,不然地图初始化的时候会黑屏一片。调试时用的是debug.keystore生成的SHA1,发布时用的是release签名文件生成的SHA1,这两个不一样,经常有人把debug的SHA1写在发布配置里,导致线上地图加载不出来。
地图初始化的代码如下:
class MapActivity : AppCompatActivity() { private lateinit var mapView: MapView private lateinit var aMap: AMap override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_map) mapView = findViewById(R.id.map_view) mapView.onCreate(savedInstanceState) aMap = mapView.map // 开启定位图层 aMap.isMyLocationEnabled = true setupLocation() loadScenicSpots() } override fun onResume() { super.onResume() mapView.onResume() } }定位权限是Android开发的老大难问题。Android 6.0之后运行时权限要动态申请,Android 12之后还有个“精确定位”和“大致位置”的区分。我在项目中先把ACCESS_FINE_LOCATION和ACCESS_COARSE_LOCATION都申请了,用户在设置里关闭精确定位时,读取到的坐标精度会明显下降,这一点在测试时很难发现,到了真机上才暴露。
4.2 景点标记与路线规划展示
景点加载完成后,要在地图上打点。这里有一个性能细节:不要一次性把所有Marker全部add到地图上,特别是景区有几十上百个景点的时候,会明显卡顿。我的做法是只加载当前视野范围内的景点,地图移动结束之后重新请求。监听AMap.OnCameraChangeListener,判断视野边界经纬度,把可视范围内的景点拉出来,然后清空旧的Marker再添加新的。功能实现不难,但体验提升非常明显。
路线规划这块,高德提供了一步到位的导航组件支持。我分了两层实现:第一层是路径预览,把路线上的坐标点画成Polyline,游客先看看路线怎么走;第二层是实时导航,启动高德导航组件,跟着语音提示走。路径预览的Polyline绘制代码如下:
val polylineOptions = PolylineOptions() .addAll(latlngList) .width(15f) .color(ContextCompat.getColor(this, R.color.route_blue)) .geodesic(true) aMap.addPolyline(polylineOptions)Polyline的宽度和颜色会影响视觉体验。太窄看不清,太宽会盖住地图道路标注。15像素配合蓝色是我实测后比较合适的组合。经纬度点列表如果超过500个,建议抽稀后再绘制,不然绘制耗时长,地图拖动时还会有明显掉帧。
4.3 网络层封装和UI状态管理
Retrofit + OkHttp的网络层封装,我踩过一个比较隐蔽的坑。接口返回的Result结构里code是业务状态码,HTTP状态码是200,两者不能混为一谈。Retrofit默认只处理HTTP层面的错误,业务层的错误码需要自己在解析器里处理。我写了一个自定义的ResponseInterceptor,统一判断code字段,非200的code直接抛业务异常,ViewModel只处理成功数据,UI层不用关心错误逻辑。
class ApiResponseInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val response = chain.proceed(chain.request()) val body = response.body?.string() val json = JSONObject(body) if (json.getInt("code") != 200) { throw ApiException(json.getString("message")) } return response.newBuilder() .body(body.toResponseBody(response.body?.contentType())) .build() } }这个拦截器还有个作用,就是保留转换后的JSON字符串给上层解析,不然Retrofit的GsonConverter会把body消费掉。刚开始没注意这个问题,老是报“EOFException: End of input”,排查了半天才发现是body被读了两次。
UI状态管理用的是ViewModel + LiveData。每个页面对应一个状态模型,比如景点列表页有加载中、成功、失败三种状态。LiveData用when表达式处理三种状态,加载中显示进度条,成功渲染列表,失败显示错误提示和重试按钮。这套状态模式我很推荐,逻辑不复杂,但用户反馈明显好了很多。
5. 联调、调试与打包交付的实战经验
5.1 前后端联调最容易踩的坑
前后端联调阶段,我整理了三个高发问题。第一个是接口地址写死的问题。Android模拟器访问本机后端,用的是10.0.2.2,真机访问用的是电脑局域网IP,很多人在这里卡住。我最后在build.gradle里配置了BuildConfig字段,Debug模式下自动使用10.0.2.2,Release模式下使用正式服务器域名,切换起来就很优雅。
第二个是JSON格式的兼容问题。后端返回的日期格式是 yyyy-MM-dd HH:mm:ss,但Gson默认解析出来的格式带T,如果不配置Gson的日期格式,直接解析会报错。在高德地图用时间戳传参时,也遇到过前后端时间单位不一致的问题,后端用秒,前端传毫秒。这些细节不在联调前达成一致,调试时贼浪费时间。
第三个坑是跨域问题。虽然移动端正常请求不会触发CORS,但我调试时用Postman和Web页面访问后端接口方便,跨域配置必须加上。我在Spring Boot里写了CorsConfig类,同时把拦截器放行预检请求,不然OPTIONS请求会被JWT拦截器拦下来,返回一堆看不懂的错误。
5.2 真机调试和模拟器差异
调试阶段我一直用模拟器测,功能流程都没问题,但拿到真机上一跑,发现定位偏差非常大。排查后发现,模拟器的GPS坐标是手动设置的,真机的定位走的是GPS + Wi-Fi + 基站混合定位,第一次冷启动定位可能需要几秒到十几秒才能稳定。我的处理是启动后先判定定位精度,如果accuracy超过100米就不在地图上画当前位置,等精度稳定后再画。
地图SDK在模拟器和真机上还有一个显著差异:Tile加载速度和缓存策略。模拟器上加载地图瓦片通常比真机慢,如果发现地图加载很久才能显示完整,先检查网络,再看是不是SDK初始化时没设置内部缓存路径。我设置了高德地图的缓存大小到100MB,并指定了外部缓存目录,实测地图加载速度提升了将近一倍。
5.3 打包签名和多渠道交付
项目的交付物包括源码、文档、调试说明和讲解视频,打包要注意区分Debug和Release。Release包必须用正式签名文件,签名文件的密码和别名信息记录在项目文档里,不然过几个月自己都忘了。用Android Studio生成签名APK的流程是Build – Generate Signed Bundle / APK,选APK,配置key store路径和密码,选择release构建变体。
有点需要特别提醒,高德地图的Key绑定包名和签名信息,如果用Debug签名打包,地图功能正常;换成Release签名打包,地图就黑了。这就是前面说的SHA1绑定问题。交付前我特意用Release包完整跑了一遍地图和导航功能,确认没问题才交给对方。
后端打包我用的是Spring Boot的Maven插件,执行mvn clean package -DskipTests生成Jar包,然后扔到服务器上用nohup java -jar huayingshan.jar > app.log 2>&1 &启动。数据库脚本放在项目的doc/sql目录下,文档里写清楚数据库版本、初始化数据说明和Redis启动前提,这部分不写清楚,对方部署的时候必问你三个问题。
6. 典型问题排查笔记与个人体会
6.1 常见问题速查表
整理一个我在开发和调试过程中遇到的高频问题表,每个都能节省后来者几个小时:
| 现象 | 根因 | 解决方案 |
|---|---|---|
| 地图黑屏/空白 | 高德Key与包名或SHA1不匹配 | 检查申请Key时填写的包名和签名信息 |
| 定位返回经纬度(0,0) | 定位权限未授予或定位服务未开启 | 检查运行时权限和系统定位开关 |
| 接口请求返回401 | JWT缺失或过期 | 查看请求头是否携带Authorization |
| 安卓端解析JSON报错 | 后端日期格式与Gson不兼容 | 配置Gson自定义日期格式 |
| 模拟器网络不通 | 使用了localhost而非10.0.2.2 | 改用BuildConfig动态配置BaseUrl |
| 导航路线显示错乱 | 坐标坐标系混淆 | 确保统一使用GCJ-02坐标系统 |
| 图片加载失败 | 服务器图片URL不可外网访问 | 检查静态资源映射或OSS配置 |
| 列表卡顿严重 | Marker一次性加载过多 | 改为可视区域内按需加载Marker |
第三个401问题值得多说一句。JWT的过期时间我设置为7天,游客打开App第一次使用不需要登录,但点击收藏或者点评时必须登录。这个过程中如果Token过期了,后端返回401,Android端需要捕获这个状态,跳转到登录页。一开始我把401当普通错误处理,用户点了收藏之后毫无反应,后来加了统一401跳转逻辑,体验才算完整。
6.2 从需求到落地的一些个人体会
这个项目从前到后完整走了一遍,我的一个深刻感受是:前后端联调和接口约定,应该放在代码开发之前。先定好接口文档,前端和后端并行开发,比先做后端再做前端效率高很多。这次我提前用Swagger把接口定义好,每个接口的请求参数、返回结构都写得清清楚楚,开发阶段基本没有扯皮。
另一个体会是地图类项目千万别忽略坐标系的坑。高德用的是GCJ-02坐标,就是俗称的“火星坐标”,Google Earth的WGS-84坐标在百度和高德地图上会偏移几百米。华为的河图地图用的是WGS-84,如果项目后续要做功能扩展,坐标转换这块一定要处理好,不然后面导航偏差的问题会非常麻烦。
最后再分享一个小技巧:景区测试时,数据别用假数据。我在测试阶段用的是华蓥山真实的景点位置和经纬度,虽然费了点时间录入数据,但联调时地图上的效果和真实使用场景一致,很多边界情况都提前暴露了。用假数据测试地图项目,测试结果基本等于没测。
如果后续要扩展这个系统,可以考虑加入AR导航、景区热力图、语音导游这些功能,或者增加一个Web管理后台,把景点数据和路线编辑从数据库脚本里解放出来。技术框架搭好了,以后的扩展都只是工作量的问题。