ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

华蓥山旅游导航系统:Spring Boot + Android原生开发实践

华蓥山旅游导航系统:Spring Boot + Android原生开发实践 做了几年的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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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后端生成TokenAndroid端保存到SharedPreferences每次请求自动带上。用JWT的好处是后端不需要维护Session对移动端天然友好也方便以后扩展Web端管理后台。密码存储这块必须强调别用明文也别用MD5。我用的BCrypt加密Spring Security框架里自带加盐之后密码即便泄露也无法直接还原。用户表里存的是BCrypt加密后的密文登录时把用户输入的密码用BCrypt的matches方法校验。这个习惯应该成为所有后端开发的肌肉记忆。登录接口的设计如下PostMapping(/api/user/login) public ResultUserVO 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 ResultRouteDetailVO 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.2Release模式下使用正式服务器域名切换起来就很优雅。第二个是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 21 启动。数据库脚本放在项目的doc/sql目录下文档里写清楚数据库版本、初始化数据说明和Redis启动前提这部分不写清楚对方部署的时候必问你三个问题。6. 典型问题排查笔记与个人体会6.1 常见问题速查表整理一个我在开发和调试过程中遇到的高频问题表每个都能节省后来者几个小时现象根因解决方案地图黑屏/空白高德Key与包名或SHA1不匹配检查申请Key时填写的包名和签名信息定位返回经纬度(0,0)定位权限未授予或定位服务未开启检查运行时权限和系统定位开关接口请求返回401JWT缺失或过期查看请求头是否携带Authorization安卓端解析JSON报错后端日期格式与Gson不兼容配置Gson自定义日期格式模拟器网络不通使用了localhost而非10.0.2.2改用BuildConfig动态配置BaseUrl导航路线显示错乱坐标坐标系混淆确保统一使用GCJ-02坐标系统图片加载失败服务器图片URL不可外网访问检查静态资源映射或OSS配置列表卡顿严重Marker一次性加载过多改为可视区域内按需加载Marker第三个401问题值得多说一句。JWT的过期时间我设置为7天游客打开App第一次使用不需要登录但点击收藏或者点评时必须登录。这个过程中如果Token过期了后端返回401Android端需要捕获这个状态跳转到登录页。一开始我把401当普通错误处理用户点了收藏之后毫无反应后来加了统一401跳转逻辑体验才算完整。6.2 从需求到落地的一些个人体会这个项目从前到后完整走了一遍我的一个深刻感受是前后端联调和接口约定应该放在代码开发之前。先定好接口文档前端和后端并行开发比先做后端再做前端效率高很多。这次我提前用Swagger把接口定义好每个接口的请求参数、返回结构都写得清清楚楚开发阶段基本没有扯皮。另一个体会是地图类项目千万别忽略坐标系的坑。高德用的是GCJ-02坐标就是俗称的“火星坐标”Google Earth的WGS-84坐标在百度和高德地图上会偏移几百米。华为的河图地图用的是WGS-84如果项目后续要做功能扩展坐标转换这块一定要处理好不然后面导航偏差的问题会非常麻烦。最后再分享一个小技巧景区测试时数据别用假数据。我在测试阶段用的是华蓥山真实的景点位置和经纬度虽然费了点时间录入数据但联调时地图上的效果和真实使用场景一致很多边界情况都提前暴露了。用假数据测试地图项目测试结果基本等于没测。如果后续要扩展这个系统可以考虑加入AR导航、景区热力图、语音导游这些功能或者增加一个Web管理后台把景点数据和路线编辑从数据库脚本里解放出来。技术框架搭好了以后的扩展都只是工作量的问题。
返回列表