最近帮一位同学梳理基于Spring Boot和Android的旅游攻略系统时,发现很多刚开始接触这个方向的人都会陷入同一个误区:以为把后台接口写完、客户端能连上就算完事,结果一跑起来全是图片裂开、时间差八小时、弱网直接闪退。作为一个带过多个类似的毕设和课设项目的人,我把整个系统的拆解、编码、联调过程中最有价值的部分整理出来,希望能帮你少走几趟弯路。这套系统最终交付的形式是源码、文档、运行视频和讲解视频,但核心始终是“怎么把一套能跑的旅游攻略系统讲清楚、做扎实”。
这套系统要解决什么问题?直观来看,是让用户在Android手机上看景点、查攻略、发表游记;往深了说,它要同时处理好两个端:Spring Boot后端负责管理景点数据、攻略内容、用户收藏和评论,Android端负责展示和交互。适合哪些人参考?正在做Java Web毕业设计的学生、想理解前后端分离的同学、以及打算把一个完整项目写进简历的开发者。本文不依赖你手头已有的源码,哪怕你从零开始搭,也能跟上思路。
1. 旅游攻略系统的整体盘子:模块划分与技术选型
1.1 为什么是Spring Boot + Android,而不是前后端分离的Web App
很多人会问:现在都流行小程序或者Vue + Web了,为什么还要用Android原生?这里有一个很现实的原因——课程和毕设的要求往往锁定在Android客户端上,而且Spring Boot是目前Java后端最主流、文档最全的框架。用Spring Boot搭RESTful API,用Android原生做界面,两者通过JSON交换数据,能清晰展示“客户端-服务端”的分层模型,答辩时也好讲。
另一个考量是学习成本。Spring Boot的自动配置极大减少了XML配置,起步快;Android端用RecyclerView加载景点列表、用ViewPager做攻略轮播,都是最经典、面试常问的技术点。相比去折腾小程序的多端适配,原生方案的问题更容易定位,源码也更容易被看懂。
1.2 功能模块怎么切才不臃肿
我在设计这个系统时,没有一上来就堆“后台管理”“支付”“地图”这些花哨功能,而是围绕“旅游攻略”这个核心场景切成了六个模块:
| 模块 | 核心功能 | 对应实体 |
|---|---|---|
| 用户模块 | 注册、登录、个人资料、收藏列表 | User |
| 景点模块 | 景点列表、分类筛选、景点详情、图片展示 | ScenicSpot |
| 攻略模块 | 攻略发布、攻略列表、攻略详情、评论 | Guide, Comment |
| 搜索模块 | 按景点名称/城市模糊搜索 | 复用景点表 |
| 收藏模块 | 用户收藏景点/攻略,我的收藏页 | Favorite |
| 数据统计模块 | 景点浏览数、攻略点赞数 | 冗余计数字段 |
这里有一个很关键的设计原则:先做最小闭环。我的第一版只做了用户、景点、攻略三个模块,收藏和搜索是后来迭代加的。如果你一开始就把所有功能铺开,数据库表会绕成一团,Android端也会因为接口太多而疲于应付。
1.3 技术栈的具体选型和版本搭配
后端这边,我推荐用Spring Boot 2.x + MyBatis-Plus + MySQL。Spring Boot 2.7是目前的稳定主力,兼容性比3.x更好,很多教学资料也以2.x为主。MyBatis-Plus能省掉大量单表CRUD的代码,遇到自定义查询再用XML写SQL。数据库用MySQL 5.7或8.0都行,注意连接字符串加上useUnicode=true&characterEncoding=utf8,不然中文会乱码。
Android端我用的技术栈是Retrofit + OkHttp + Gson + RecyclerView + Glide。RecyclerView负责列表,Glide负责图片加载。网络层用Retrofit的@GET、@POST注解来声明接口,配合GsonConverterFactory自动解析JSON。如果你对协程不熟,先用Call 同步回调的方式,逻辑清晰,不容易踩生命周期坑。
开发工具方面,后端用IntelliJ IDEA + Navicat,Android用Android Studio。要注意后端JDK版本最好和Spring Boot要求的匹配,我用的JDK 1.8,Android Studio的Gradle版本用7.4左右,太高会导致工程编译任务太重,低版本模拟器反而跑不起来。
2. Spring Boot后端:从数据库表设计到接口权限控制
2.1 数据库表设计:为了少改代码,我画了三遍
这个系统的核心数据关系其实很直白:用户收藏景点,用户对攻略发表评论。我第一次设计时只用了一张guide表存用户Id和内容,后来发现无法实现“我的收藏”和“热门景点排行”,只能返工。最终的库表结构如下:
user:主键id、username、password(MD5加盐)、nickname、avatar、create_timescenic_spot:主键id、name、city、cover_image、images(JSON数组字符串)、description、ticket_price、open_time、visit_duration、browse_countguide:主键id、user_id、scenic_id、title、content、like_count、create_timecomment:主键id、guide_id、user_id、content、create_timefavorite:主键id、user_id、target_id、target_type(1景点/2攻略)、create_time,联合唯一索引保证不重复收藏
这里要特别说下images字段。很多人会单独建一张scenic_image表,然后对外返回时再join拼接,其实对于这种量级的系统,直接用JSON字符串存储反而更简单。Java后端定义成String,取出后用JSONArray.parseArray转成List<String>传给前端,不用额外建表,联调也更轻松。
还有一个容易踩的坑:MySQL的字段名尽量别用like,它是SQL关键字。我的like_count没问题,但如果你字段直接叫like,写SQL都得加反引号,非常折磨。
2.2 接口设计:RESTful风格下的一次完整请求走读
后端接口采用标准RESTful风格,返回统一封装体Result。结构固定为{code: 200, message: "成功", data: ...},Android端解析时只要对这个结构做一次封装即可。核心接口大概长这样:
GET /api/scenic/list?page=1&size=10&city=杭州 GET /api/scenic/detail?id=1 GET /api/guide/list?scenicId=1 POST /api/guide/publish (Body: title, content, scenicId, token) POST /api/favorite/add (Body: targetId, targetType, token) GET /api/user/favorites (token)登录接口我特意设计成POST /api/user/login接收JSON,返回token。这个token我在Android端用SharedPreferences存储,后续每个需要认证的请求都在拦截器里拼上Authorization头。后端通过拦截器统一校验,未带token直接返回401。这么做有什么好处?你可以把用户身份的校验逻辑集中在一个地方,而不是每个Controller都去翻Session。
2.3 权限与安全:不能被绕过的登录校验
很多学生项目有个通病:登录接口写好了,但收藏接口没有校验token,随便一个请求就能操作别人的收藏。我在开发时用Spring Boot的HandlerInterceptor做了统一拦截。自定义一个AuthInterceptor,在preHandle中从请求头取token,用io.jsonwebtoken.JwtUtil解析用户id,解析成功就把userId放入ssoRequest的attribute里。
这里我踩过一个很经典的坑:JWT依赖的版本冲突。jjwt的0.9.x和1.0.x的parse方法签名不一样,0.9.x用的是parser().parseClaimsJws(token).getBody(),1.0.x改成了parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload()。如果你拿旧代码到新依赖下跑,直接编译不过。所以一定要锁死依赖版本,在pom.xml里明确写jjwt 0.9.1,不要用latest。
2.4 图片上传与静态资源映射
景点图片不可能全部靠数据库存URL,开发初期我们用的是本地存储。在Spring Boot里配置资源映射:
spring: servlet: multipart: max-file-size: 10MB web: resources: static-locations: file:D:/travel/uploads/然后用@PostMapping("/api/upload")接收MultipartFile,把图片重命名存到D:/travel/uploads/,返回拼好的访问路径。Android端Glide加载时直接用http://你的后端IP:8080/uploads/xxx.jpg。
这里有一个大家都会遇到的坑:如果你后端的IP是局域网地址,那么返回的图片URL必须是Android设备能访问到的IP,不能是127.0.0.1。手机上联调时,要么把后端绑到0.0.0.0,要么把返回的URL统一替换成电脑的局域网IP。我习惯在application.yml里配置一个base-url属性,上传后拼路径时读取它,方便切换。
3. Android端搭建:旅游景点列表、攻略详情与搜索怎么做
3.1 项目分层:model、api、ui、adapter各司其职
Android端的代码结构,我建议按功能分层而不是按页面堆文件。定义四个基础包:
model:和JSON结构一一对应的实体类,如ScenicSpot、Guide、Resultapi:Retrofit接口定义和网络管理单例ui:各个Activity/Fragment,比如MainActivity、ScenicDetailActivity、GuidePublishActivityadapter:RecyclerView的Adapter和ViewHolder
这样做的好处是,当后端接口字段变动时,你只需要改model类,UI层基本不动。我见过很多代码把网络逻辑直接写在Activity里,结果一个页面几百行,改接口时到处找URL,非常痛苦。
3.2 网络层的骨架代码
以Retrofit作为例子,先定义一个接口:
public interface ApiService { @GET("api/scenic/list") Call<Result<List<ScenicSpot>>> getScenicList( @Query("page") int page, @Query("size") int size, @Query("city") String city ); @POST("api/favorite/add") Call<Result<Void>> addFavorite(@Body AddFavoriteRequest request); }网络管理单例里设置OkHttp,并加上带token的拦截器:
OkHttpClient okHttpClient = new OkHttpClient.Builder() .addInterceptor(chain -> { Request original = chain.request(); Request.Builder builder = original.newBuilder(); if (token != null) { builder.header("Authorization", token); } return chain.proceed(builder.build()); }) .connectTimeout(10, TimeUnit.SECONDS) .build(); Retrofit retrofit = new Retrofit.Builder() .baseUrl(BASE_URL) .client(okHttpClient) .addConverterFactory(GsonConverterFactory.create()) .build();在回调中,要统一判断Result.getCode()是不是200。是200才取data,否则Toast出message。这个判断放在每个网络请求里面容易重复,建议写一个抽象的BaseCallback<T>,在onResponse里剥离结果。
3.3 景点列表的加载与分页
景点列表页是我实现的第一块内容,也是Android端最典型的一段逻辑。页面结构是顶部一个搜索框加水滴筛选(城市),下方一个RecyclerView。加载分页时,我用LinearLayoutManager配合RecyclerView.OnScrollListener实现上拉加载。
需要注意两点:
- 分页参数同步:每次加载完一页,
currentPage要更新,并且在后端返回的list.size()小于pageSize时设置isLastPage = true,避免发无意义的请求。 - 空列表处理:后端返回空数组时,Adapter要显示一个空布局,而不是留下一个空白RecyclerView。
3.4 攻略详情的图文混排
攻略详情页是整个系统里最“重”的一个页面。攻略内容可能包含多张图片和长文本。最初我打算用WebView加载HTML字符串,后来发现转义符号处理太麻烦,就改用NestedScrollView嵌套多个子View:顶部放标题、作者、时间,中间放内容文本,内容中的图片URL列表用动态添加ImageView的方式依次显示。
这样动态添加视图的缺点是布局计算复杂,但胜在方便控制点击事件(比如点击图片可以查看大图)。如果你有kotlin协程的基础,也可以换成Compose,但考虑到源码可读性,我还是保持原生View体系。
3.5 搜索功能的本地优化
后端搜索接口用LIKE '%' #{keyword} '%'查询景区名称和攻略标题,这个写法在数据量低于十万时完全够用。Android端的搜索页不需要每次输入都请求后台,我做了300毫秒的防抖:EditText.addTextChangedListener里用Handler.removeCallbacks和postDelayed,这样用户输入停顿后再发请求,体验更好,后端压力也小。
还有一个很小的细节:搜索关键词里含空格或%时,后端SQL要处理好,避免变成通配符查询。最简单的方法是在Java层用MyBatis-Plus的Wrapper.like(...),它会自动帮我们处理特殊字符。
4. 联调阶段最难缠的问题:图片加载、时间格式和弱网处理
4.1 图片来源与Glide的缓存陷阱
旅游景点系统最依赖的就是图片,图片挂了整个页面就废了。联调时最常出现的问题是:后端返回的图片URL是本地绝对路径,Android端拿不到;或者图片地址没加http://前缀,被Glide当成相对路径解析。
我在实现时给Glide统一配了一个AppGlideModule,自定义safeUrl处理,把纯路径前缀加上后端的baseUrl。另外有个细节:本地联调修改图片后,Glide缓存可能还显示旧图。调试时可以在初始化里配置.skipMemoryCache(true).diskCacheStrategy(DiskCacheStrategy.NONE),或者直接清应用数据。不然你明明换了图片,界面死活不变,很容易误以为接口写错了。
4.2 时间戳与LocalDateTime的格式统一
后端的create_time用的是MySQL的DATETIME,Java属性是LocalDateTime,默认序列化出来是"2025-06-11T15:30:00",Android端如果用简单substring截断,可读性还行,但如果你直接展示,中间那个T看起来很怪。更严重的是,如果后端服务器和手机时区不一致,时间会差八小时。
我的解决方案是后端统一用配置:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8同时在Android端定义好GsonBuilder().setDateFormat("yyyy-MM-dd HH:mm:ss"),保证双方格式一致。多端联调时,最忌讳的就是一边用时间戳一边用字符串,两边各转各的,最后对不上。
4.3 弱网环境的超时与重试
移动端网络不像电脑那么稳定,我在开发时的默认策略是:连接超时5秒、读超时10秒、写超时10秒。对于列表接口,如果请求失败,可以做一个“重试按钮”而不是直接空白。我用了一个FastJSON封装Result,在失败回调里根据code字段提示网络异常,并把按钮状态恢复为“加载失败,点击重试”。
还有一个关于图片的弱网优化:Glide加载列表图建议使用.thumbnail(0.3f),先加载一个低分辨率缩略图,再加载原图。这样即使后端图片很大,列表滑动也不至于卡顿。我亲眼见过一张三兆多的照片直接把RecyclerView拖动变成PPT,加了这个就流畅多了。
4.4 真机调试时最容易忽略的底栏导航问题
很多人用手机调试时会发现:界面底部被系统导航栏挡住。如果你使用的是普通的Activity,需要在主题里启用android:windowTranslucentStatus和android:windowTranslucentNavigation,或者给根布局设置fitsSystemWindows="true"。我当时忽略了这个问题,导致攻略详情页的发布评论按钮被导航栏盖住一半,点击反应特别奇怪。
修复的方法很简单:在styles.xml中给Activity的主题加上<item name="android:fitsSystemWindows">true</item>,同时布局的根节点保持一个背景色即可。这样系统会自动为我们留出安全区域,不需要手动写一堆padding计算。
5. 从能跑到能交付:源码、文档、运行视频和讲解视频怎么准备
5.1 目录结构清晰,比代码本身更重要
一个项目交付给别人的时候,第一印象就是目录结构。我看到过太多学生项目,所有Java文件堆在一个包底下,根本分不清哪个是控制层哪个是实体层。建议把Spring Boot项目按controller/service/mapper/entity/config分包,Android端按api/model/ui/adapter分包。压缩包内根目录放README.md,写清楚项目简介、技术栈、启动步骤和账号密码。
源码中不需要包含target目录和node_modules这类构建产物,否则压缩包会大得离谱。我一般用.gitignore把无关文件屏蔽之后,再压缩。
5.2 运行视频:展示出一条完整的用户链路
运行视频不是把每个页面都点一遍,而是要按照“注册/登录 -> 浏览景点 -> 查看详情 -> 发布攻略 -> 收藏 -> 查看我的收藏”这样的业务链路来录。录制时用模拟器加录屏工具,分辨率不要太高,20分钟以内的视频即可。关键操作之间稍微停顿两秒,方便观看者看清点击位置。
如果视频过长,可以剪辑成两段:第一段是“演示流程”,第二段是“核心代码走读”。我见过一些同学把运行视频直接录成三十分钟的流水账,答辩时老师根本不想看。精简、有重点的视频,反而能留下好印象。
5.3 讲解视频:别念PPT,讲清楚“为什么”
讲解视频最忌讳的就是照着源码把每个方法读一遍。我建议按这个顺序讲:先讲项目背景和功能结构,再画一张简单的请求流程图,最后挑选两个技术亮点,比如“JWT登录拦截”“RecyclerView分页加载”,讲原理和实现过程。讲解时不用追求把底层源码背下来,但要能回答“为什么用Redis/为什么不用Ajax这类问题”。
结合我之前带项目答辩的经验,老师最喜欢问三个问题:数据库表如何关联?用户登录后怎么做权限校验?图片上传后怎么访问?这三个问题如果能在讲解视频中主动讲透,线下答辩就稳了。
5.4 文档组织:需求文档、设计文档和操作手册各司其职
交付文档一般分三份:
- 需求文档:写清楚系统背景、用户角色、功能列表、用例描述。不需要长篇大论,每个功能用表格列出前置条件、主流程和异常流程。
- 设计文档:包含数据库ER图、接口文档、核心类设计。接口文档我推荐用Markdown表格,写入
api.md,列出URL、请求方法、请求参数、返回示例。 - 操作手册:讲解怎么安装JDK、配置MySQL、导入项目、启动后端、打开Android工程。别默认对方懂技术,每一步都要写清路径。
我个人习惯把“测试账号”单独写在最后一页:admin / 123456,用户user / 123456,方便快速演示。如果你把账号密码埋在代码里,别人找起来很费劲。
6. 复盘:再做一次这个项目,我会在哪些地方做得更快
6.1 先定接口文档,再动代码
如果重新来,我会用Apifox或YApi先把所有接口的定义写好,让Android端和后端同时开工。之前我先写后端,接口写一版改一版,客户端一直在被迫配合调整,浪费了大量时间。先定义GET/POST、路径、参数、返回示例,两边照着契约各自开发,联调时基本一遍通过。
6.2 用MyBatis-Plus的代码生成器
一开始我手工写实体类和Mapper,写多了实在无聊。后来发现MyBatis-Plus的AutoGenerator可以一键生成entity/mapper/service/controller。虽然生成的代码还需要微调,但至少省去了一半的模板代码。你如果自己搭项目,也可以从官网抄一段生成器代码,改成自己数据库连接即可。
6.3 尽早用真机测试
模拟器大多使用电脑网络直通,很多在真机上才会暴露的网络问题,模拟器里完全看不出来。比如DNS解析、刘海屏、不同手机的分辨率等。我在交付前一周开始用两台真机测试,一台用电信流量,一台连宿舍WiFi,这样能模拟相对真实的弱网切换场景。发现问题后,针对超时和重试逻辑做了优化,比一直盯着模拟器看成效高得多。
6.4 日志是排障的现代级工具
后端开发要养成关键操作打日志的习惯。我在每个Controller的入口打一行log.info("请求参数: {}", params),在登录拦截器里也打一行token校验结果。Android端也保留Log.d/TAG输出响应结果。联调时两个人各自看日志,五分钟内就能定位问题,而不是靠猜。
我曾经遇到过一个非常诡异的问题:数据库明明有数据,但列表接口返回空数组。后来打开日志发现SQL执行时报了Invalid bound statement,原来是我把Mapper的XML文件放在resource目录下,但路径没匹配上,扫描不到SQL语句。这个问题如果没有日志,可能要排查很久。
7. 给你的最后一点建议
如果你打算基于这个标题去开发或改造一套自己的旅游攻略系统,我的经验是:先跑通最小闭环,再逐步加功能。最开始哪怕只有一个列表页和详情页,也把前后端请求链路完整跑一遍,证明架构是通的。之后每加一个模块,都是在这个闭路上扩展,风险会小很多。
也别忽略“讲解”这个环节。代码写得很漂亮但讲不出来,在毕业设计或简历面试中的价值会大打折扣。试着把这个系统当成一个产品去介绍:解决了什么痛点、后端如何设计、客户端如何配合、哪里是我认为的亮点。想清楚这些,你会发现源码、文档和演示视频,其实只是你思考过程的外显物。
分享一个小技巧:在Android端所有网络请求的BaseCallback里,我习惯把message字段默认拉取出来,如果code != 200就Toast出来。这样在演示时即使真的报错,也可以自然解释为“这是后端返回的业务提示”,而不是一脸惊慌地翻代码。这个习惯让我在几次演示突发小状况时都稳住了场面。