1. 为什么我选“日记APP”当毕设:需求拆解与选型逻辑
每年到了毕设开题的时候,总有人问我:“Java方向的毕设做什么最简单又能稳过?”我的回答一直是:做工具类APP,尤其个人日记本这种,性价比极高。
先说原因。个人日记本这类项目,功能边界非常清晰,无非就是用户的注册登录、日记的增删改查、心情记录、图片附件、时间线展示这几件事。它的业务复杂度适中,能充分展示你的工程能力,又不至于把自己绕进高并发的泥潭里。更重要的是,它天然就是一个前后端分离的完整系统——Android作为移动端展示与交互,SpringBoot作为后端服务提供接口,两边各司其职,工作量均衡,最能体现“我有全栈思维”这个点。答辩的时候,老师问“你这个系统架构是什么”,你可以坦然回答:“Android客户端负责UI和本地数据缓存,SpringBoot负责业务逻辑和持久化,两端通过RESTful API通信”,这个回答本身就是完整的项目概括。
再说选型。用户给的标题里已经锁死了技术栈:客户端是Android原生,服务端是SpringBoot,数据库层面配合MyBatis。我在实际开发里验证过这套组合,确实适合毕设场景——Android原生生态成熟,资料多;SpringBoot自动配置极大减少了搭建成本;MyBatis的SQL可控性强,遇到复杂查询可以手写,不像JPA那样遇到坑不好排查。所以我的建议是:别折腾微服务,别上Redis缓存,就把这套基础组合做到极致,功能完整度比技术炫技在答辩中更值钱。
整套系统拆开来看,核心功能就这些:
- 用户模块:注册、登录、个人信息维护,登录态用Token保持
- 日记模块:新增、编辑、删除、分页查询、按日期/关键词搜索
- 情绪标注:每篇日记可标记心情,比如开心、平淡、低落、愤怒
- 图片附件:拍照或相册选图,随日记一起上传
- 统计展示:查看一周/一月的心情变化曲线,这样“智能”二字就落到了实处
- 本地缓存:无网络环境下也能看近期日记,网络恢复后自动同步
这套功能设计下来,既覆盖了常规CRUD,又有“心情统计”“本地缓存”这种可以讲出亮点的模块,无论你的论文写“系统设计”还是“实现难点”,都有素材。
2. 数据库与接口先行:把日记系统的地基打牢
我做过不少毕设辅导,发现很多同学的习惯是“先写代码再做表”,结果到联调阶段被字段对不上、接口设计不合理折磨到崩溃。正确的做法应该是:先把ER图和数据表结构定下来,再把接口契约列清楚,最后才动手写代码。
2.1 数据表设计与ER图关键点
我的项目里一共设计了三张核心表,简洁但覆盖所有业务需求。
用户表 t_user:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | BIGINT | 主键,自增 |
| username | VARCHAR(50) | 用户名,唯一索引 |
| password | VARCHAR(100) | 加密后的密码 |
| nickname | VARCHAR(50) | 昵称 |
| avatar | VARCHAR(255) | 头像URL |
| create_time | DATETIME | 注册时间 |
密码加密是必须做的,哪怕只是毕设答辩,也不能明文存密码。我用的是Spring Security的BCryptPasswordEncoder,这个加密方式自带盐值,相同密码每次加密结果都不一样,安全性足够。
日记表 t_diary:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | BIGINT | 主键 |
| user_id | BIGINT | 关联用户ID,普通索引 |
| title | VARCHAR(100) | 日记标题 |
| content | TEXT | 正文内容 |
| mood | TINYINT | 心情状态,0-4对应五档 |
| weather | VARCHAR(20) | 天气,如晴、多云、雨 |
| image_urls | VARCHAR(1000) | 图片URL,多个用逗号拼接 |
| is_delete | TINYINT | 逻辑删除标记,0正常1删除 |
| create_time | DATETIME | 创建时间 |
| update_time | DATETIME | 最后修改时间 |
看到is_delete这个字段了吗?这是我在实际项目中踩坑换来的经验。物理删除会让“回收站”“数据恢复”这些功能后续没法扩展,而且误删数据找不回来。毕设项目用逻辑删除是加分项,答辩时提到“我做了逻辑删除而非物理删除,保证数据可回溯”,老师会认为你有工程意识。
心情统计表(或视图):我这里的统计没有额外建表,而是通过SQL对t_diary表按mood和create_time进行GROUP BY聚合查询。这样做数据最实时,也省去维护统计表的麻烦。ER图中体现出user与diary的一对多关系即可,写论文时这是必画的图。
2.2 SpringBoot接口设计:RESTful风格的统一约定
接口设计遵循RESTful风格,统一返回Result对象。我在项目里定义了一个通用返回体:
public class Result<T> { private Integer code; // 200成功,500失败,401未授权 private String message; // 提示信息 private T data; // 业务数据 }所有接口一律返回这个结构,Android端解析JSON时统一处理code,这样错误处理逻辑就收敛到了一处,不会出现“接口返回格式不统一、客户端解析乱七八糟”的问题。
核心接口列表如下:
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | /api/user/register | 用户注册 |
| POST | /api/user/login | 用户登录,返回Token |
| GET | /api/diary/page | 分页查询日记列表 |
| GET | /api/diary/{id} | 获取日记详情 |
| POST | /api/diary | 新增日记 |
| PUT | /api/diary/{id} | 修改日记 |
| DELETE | /api/diary/{id} | 逻辑删除 |
| GET | /api/diary/search | 关键词搜索 |
| GET | /api/diary/mood/stats | 心情统计 |
| POST | /api/upload | 图片上传 |
关于登录态,我用的是JWT(JSON Web Token),而不是传统的Session。Android端登录成功后拿到Token,存到SharedPreferences里,后续每个请求放在Header的Authorization字段中。后端通过拦截器校验Token,解析出对应的userId,再处理业务请求。这么做的好处是服务端无状态,以后想横向扩展也不受Session黏滞限制。
2.3 一个关键细节:字段校验与异常处理
接口看似简单,但真正的坑在细节里。比如用户注册时,用户名是否重复?密码是否为空?日记的content能不能为空?如果这些不做校验,前端传个空字符串过来,数据库存了半天,查询端还要到处判空,后患无穷。
SpringBoot处理这个问题的标准姿势是用@Validated注解配合@NotBlank、@Size这些Java Bean Validation规范:
@PostMapping("/register") public Result<String> register(@RequestBody @Validated UserRegisterDTO dto) { // dto.username 非空,dto.password 长度6-20位 // 校验不通过时,框架自动抛出MethodArgumentNotValidException }我在全局异常处理器里捕获所有校验异常,统一封装成Result返回给客户端。客户端拿到统一的code,弹Toast提示,体验就非常干净。这套组合写进论文也算是一个“系统鲁棒性设计”的点位。
3. Android客户端核心模块实现:从登录到日记列表的完整链路
Android端的开发,我用的语言是Java(标题里明确说了Java),IDE是Android Studio。在动手之前想清楚一个问题:客户端的数据源是只走后端接口,还是本地SQLite与后端并存?
我的方案是:核心数据走后端接口,但本地用SQLite做缓存,网络不可用时降级读取本地缓存。这个方案比纯网络请求复杂一些,但“离线可用”这四个字在功能性上很有说服力。下面按开发顺序拆解各模块。
3.1 网络层封装:Retrofit2 + OkHttp + 统一拦截器
网络层是整个APP的骨架。我用Retrofit2作为HTTP客户端框架,配合OkHttp拦截器实现Token注入和日志打印。
首先定义一个拦截器,每次请求自动带上Token:
public class AuthInterceptor implements Interceptor { @Override public Response intercept(Chain chain) throws IOException { Request original = chain.request(); String token = TokenManager.getInstance().getToken(); if (token != null) { Request request = original.newBuilder() .header("Authorization", token) .method(original.method(), original.body()) .build(); return chain.proceed(request); } return chain.proceed(original); } }TokenManager就是一个封装了SharedPreferences读写的小工具,单例模式,全局可访问。填Token这步做好之后,业务层的每个API接口方法就不用重复传Token参数了。
Retrofit接口定义示例:
public interface ApiService { @POST("api/user/login") Call<Result<LoginResponse>> login(@Body LoginRequest request); @GET("api/diary/page") Call<Result<PageResult<Diary>>> getDiaryPage(@Query("page") int page, @Query("size") int size); @POST("api/diary") Call<Result<Long>> addDiary(@Body Diary diary); @Multipart @POST("api/upload") Call<Result<String>> uploadImage(@Part MultipartBody.Part file); }这里要强调一个容易踩的坑:Retrofit的BaseURL必须以“/”结尾,否则运行时会报IllegalArgumentException。我第一次搭的时候就是漏了结尾斜杠,排查了半小时。
3.2 登录页与Token持久化
登录页的布局很简单:用户名、密码、登录按钮、跳转注册页的链接。但逻辑不能只做“请求成功就跳转”——错误码的提示必须到位。比如用户密码错了,后端返回code=500且message=“用户名或密码错误”,客户端必须把这个message展示出来,而不是笼统提示“请求失败”。
登录成功后:
private void handleLoginSuccess(LoginResponse response) { TokenManager.getInstance().saveToken(response.getToken()); TokenManager.getInstance().saveUserId(response.getUserId()); Intent intent = new Intent(LoginActivity.this, MainActivity.class); startActivity(intent); finish(); }Token持久化是登录态的核心,我在SharedPreferences之外,还给SharedPreferences本身加了一层加盐BASE64编码,防止Root设备上明文被读出来。说实话毕设项目中很少人会注意到这个细节,但论文里写“对敏感信息进行二次编码存储”,评审老师的印象分会不一样。
3.3 日记列表页:RecyclerView + 多类型布局 + 下拉刷新
日记列表是APP的主界面,我用的是SwipeRefreshLayout包裹RecyclerView的方案。Adapter继承RecyclerView.Adapter,在onBindViewHolder里把标题、正文截取片段、心情图标、日期填充进去。
日记列表一个值得注意的点是:正文在列表中只显示截取的摘要,而不是全部文本。我在后端查询时直接通过SQL截取前80个字符返回摘要字段,一方面减少网络传输量,一方面客户端渲染更快。这是典型的“接口设计为UI服务”的思路。
下拉刷新逻辑也很关键。SwipeRefreshLayout的OnRefreshListener里重新请求第一页数据,成功时调用adapter.setData和swipeRefreshLayout.setRefreshing(false)。分页加载则通过RecyclerView的滑动监听,在滑到底部前一个Item时触发加载下一页。
这个页面的状态处理我踩过很多次:请求失败、加载完毕无更多数据、首次加载 loading 中,这三种状态必须有对应的UI表现。否则用户网络不好时点进来,白屏加转圈,体验极差。我的做法是一个FrameLayout里放三四个View,根据状态切换visibility。虽然实现笨一点,但稳。
3.4 写日记页:富文本编辑与图片选择
写日记页面是另一个技术点较多的模块。我的设计是:顶部标题EditText,中间正文EditText(支持多行),底部一排图标按钮(心情选择、天气选择、图片选择)。
图片选择我用的是系统Intent调起相册或相机,然后用Glide加载到ImageView显示。选完图片要上传时,有个大坑:跨Android版本的文件路径处理。Android 10起,直接拿相册返回的data.getData()去构造File会碰到权限问题,必须通过ContentResolver解析URI拿到真实路径或直接读输入流。
我的上传代码在Android 10以上是用getContentResolver().openInputStream(uri)读取输入流,包装成MultipartBody.Part:
InputStream is = getContentResolver().openInputStream(uri); byte[] bytes = readAllBytes(is); RequestBody body = RequestBody.create(MediaType.parse("image/*"), bytes); MultipartBody.Part part = MultipartBody.Part.createFormData("file", "diary_" + System.currentTimeMillis() + ".jpg", body);这样做不依赖外部存储权限,适配性好。Android 6以上的运行时权限申请也需要提前处理,我封装了一个PermissionHelper类,统一处理相机、相册、存储权限的申请回调。
3.5 本地SQLite缓存:Room还是原生SQLite?
本地缓存方案我在Room和原生SQLite之间纠结过。Room需要引入Room Runtime和Room Compiler两个依赖,并且需要定义Entity、Dao、Database三层,代码量不小;原生SQLiteOpenHelper简单直接,但代码写起来相对繁琐,查询要自己拼SQL。
我最终选的方案是:网络层返回的日记列表直接以JSON形式缓存到本地文件,用SharedPreferences记录缓存时间,而不是建SQLite表存结构化的日记数据。原因很务实:日记列表的缓存scene比较单一,读出来反序列化成List 直接能用,不用建表、不用SQL映射,代码量少一半。而且日记本身属于低频更新数据,JSON文件的整体替换策略已经够用。
如果你的论文想突出“数据持久化”,那就用Room把Diary实体映射到本地表,再写一个简单的同步逻辑。两种路线都可以,但不要两边都做不彻底。
4. 让日记不只是文本:心情标注、图片附件与统计曲线的功能增强
CRUD人人会写,真正让一个毕设项目从“及格”到“良好”甚至“优秀”的,是那些能体现设计思考的特色功能。这一节我拆解三个增强模块的实现思路。
4.1 心情标注与天气记录
日记不只是文字,它承载的是用户彼时的情绪和场景。所以我在日记里加了两个轻量字段:mood(心情枚举)和weather(天气字符串)。
心情用TINYINT存储,对应5个等级:
| 值 | 含义 | 图标 |
|---|---|---|
| 0 | 低落 | 灰色乌云 |
| 1 | 平淡 | 蓝色平静 |
| 2 | 还好 | 黄色一般 |
| 3 | 开心 | 橙色笑脸 |
| 4 | 兴奋 | 红色太阳 |
写日记的页面中,心情选择是一排单选图标,选中状态高亮,存进Diary对象。列表页中根据mood值加载不同的表情图标。这个功能虽然简单,但“把情绪结构化”这一点在论文的需求分析里很出彩。
4.2 图片上传与显示
图片上传的接口用了Multipart格式,SpringBoot端的接收逻辑:
@PostMapping("/api/upload") public Result<String> upload(@RequestParam("file") MultipartFile file) { if (file.isEmpty()) { return Result.error("文件为空"); } String originalFilename = file.getOriginalFilename(); String ext = originalFilename.substring(originalFilename.lastIndexOf(".")); String filename = UUID.randomUUID().toString().replace("-", "") + ext; // 保存到服务器指定的上传目录 File dir = new File(System.getProperty("user.dir") + "/upload/"); if (!dir.exists()) dir.mkdirs(); file.transferTo(new File(dir, filename)); return Result.success("/upload/" + filename); }上传文件保存到服务器本地目录,返回可访问的URL,Android端用Glide加载。毕设场景不需要考虑分布式存储,本地磁盘足够。但要注意:SpringBoot默认上传文件大小限制是1MB,必须通过配置调大,否则选个大图直接报错。
spring.servlet.multipart.max-file-size=10MB spring.servlet.multipart.max-request-size=50MB还有一个细节是图片显示前必须用Glide做加载占位和失败占位,否则弱网环境下图片区域一片空白,体验很突兀。
4.3 心情统计曲线:把SQL聚合结果变成可视化图表
心情统计是本项目里“智能”二字的最佳载体。后端的统计接口通过以下SQL,按日期和心情值聚合出近7天的分布:
SELECT DATE(create_time) AS day, mood, COUNT(*) AS cnt FROM t_diary WHERE user_id = #{userId} AND is_delete = 0 AND create_time >= DATE_SUB(CURDATE(), INTERVAL 6 DAY) GROUP BY DATE(create_time), mood ORDER BY day我在这里用了一个小技巧:聚合查询在内存中整理成7x5的二维数组结构,天数做行、心情等级做列,没数据的格子补0。这是为了让Android端绘图时不用处理稀疏数据,直接拿到完整的矩阵就能画堆叠柱状图。
Android端图表绘制我用了MPAndroidChart(GitHub上的开源图表库),添加依赖后,设置X轴标签为日期,Y轴为数量,一个多小时就能完成:
implementation 'com.github.PhilJay:MPAndroidChart:v3.1.0'画图的关键坑是X轴Label过多时会显示重叠,需要设置setLabelCount(7)并强制setGranularity(1f)。另外一个很丑的技术问题是:如果某天没有任何日记,柱状图中间会空一块,视觉上非常难看,所以后端补齐0的策略在这里发挥了作用,图形看起来连续、专业。
5. 联调、打包与答辩:真实工程里的坑与经验
前四节把整个系统的核心模块梳理了一遍。这一节聊点“文档里不写”的东西——联调阶段容易踩的坑、APP打包的注意事项,以及答辩时老师常问的问题。这些经验是我自己走过弯路之后总结出来的,能帮你少折腾好几天。
5.1 联调阶段最常见的三个问题
第一个:Android模拟器的网络地址问题。
如果你用Android Studio自带模拟器,访问电脑本机的SpringBoot服务,不能用localhost或127.0.0.1,因为模拟器里的这个地址指向的是模拟器自身。正确写法是http://10.0.2.2:8080,这是模拟器映射到宿主机的一个特殊地址。我当时忘了这一点,请求一直超时,还以为是后端端口被占用,排查了半下午。真机调试的话,直接把地址改成电脑的局域网IP,但要保证手机和电脑连同一个WiFi。
第二个:数据库时区问题。
SpringBoot连接MySQL,如果JDBC URL没有加serverTimezone=Asia/Shanghai,夜里0点附近插入日记,存储的时间可能比本地时间少了8小时。因为MySQL驱动默认用的是UTC。解决方案:
spring.datasource.url=jdbc:mysql://localhost:3306/diary?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai时区问题在时间线排序时尤其明显——你以为用户晚上11点写的生活记录,排序却跑到了第二天凌晨,答辩演示时出现这个bug会非常尴尬。
第三个:Android的明文HTTP流量限制。
Android 9(API 28)开始,默认禁止明文HTTP请求。如果你的后端是http://不是https://,请求会直接被拦截并抛出流明错误。解决方式是在AndroidManifest.xml的application节点里加上:
android:usesCleartextTraffic="true"或者针对调试环境配置networkSecurityConfig只允许特定域名的HTTP请求。毕设场景直接加usesCleartextTraffic最省事,但写论文时如果需要体现安全意识,可以提一句“生产环境建议配置HTTPS”。
5.2 APP签名打包与安装
开发阶段跑Debug包没问题,但答辩演示最好打一个Release安装包,免得现场连接AS的时候环境出幺蛾子。
生成签名APK的步骤是:Build -> Generate Signed Bundle / APK -> 选择Create New Key Store,填好别名、密码、国家代码后Next,等到Gradle构建完成,产物就在app/release/目录下。
一个值得注意的坑是:Release包的ProGuard / R8混淆配置不当会导致Gson解析实体类全变成null。因为实体类的字段被混淆成了a、b,而Gson用的是反射按字段名解析JSON。解决方案是给实体类所在的包单独关闭混淆,或者在proguard-rules.pro里加规则:
-keep class com.example.diary.entity.** { *; } -keep class com.example.diary.dto.** { *; }另外要记得在build.gradle里把混淆开关配上:
buildTypes { release { minifyEnabled false // 项目不大,直接关掉混淆,图个稳定 proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } }我实际打Release包时把minifyEnabled暂时设成false,等到论文写“系统安全设计”时再提混淆方案——毕竟演示阶段稳定性第一。
5.3 答辩高频问题与应对思路
根据我当过多届毕设评审“亲友团”的观察,老师对这类项目的提问方向是固定的,提前准备这几个问题的答案,答辩基本稳:
“为什么用JWT而不用Session?”
答:JWT无状态,服务器不保存会话信息,天然适合前后端分离和横向扩展。用户登录后得到Token,客户端每次请求携带,服务端只做验签。不需要Redis保存Session,也不需要处理Session过期与迁移问题。
“逻辑删除和物理删除的区别?”
答:物理删除是真正从数据库里DELETE掉记录,无法恢复;逻辑删除是UPDATE一个标记位,查询时过滤掉已删除数据。我采用逻辑删除是为了保留用户所有历史数据,防止误删,也方便后续扩展回收站功能。代价是每张业务表多一个字段,查询时多一个过滤条件。
“心情统计的数据一致性怎么保证?”
答:统计不单独建表,直接基于diary表的mood和create_time字段通过SQL实时聚合。这样保证统计结果永远不会与日记数据不一致——因为数据源是同一个。缺点是聚合查询在大数据量下性能会下降,但个人日记场景的量级完全不会触发这个问题。
“离线缓存为什么用JSON文件,不建SQLite?”
答:日记列表读取场景是整体读写,JSON文件整体序列化和反序列化最简单,不用建表维护字段映射。如果未来要做复杂条件查询,就可以切换到Room,在数据访问层做抽象替换,业务层不受影响。
5.4 初始数据长的不好看怎么办
这块属于血泪建议,一定要在答辩前给账号里先写好8-10篇内容充实的日记,最好是从当天往前连续覆盖两周的数据,每天的mood尽量不同。因为老师点开APP第一眼看的是列表和统计页,如果数据稀疏,统计曲线全是0,整体demo效果直接打折。你可以写点技术学习感悟类的随笔,比如“今天学会了Retrofit拦截器”“SQL里的DATE_SUB函数真好用”,内容量和日期分布都要有,这样演示时下拉刷新、统计曲线动起来特别有说服力。
数据库初始化脚本里也可以造一批测试数据,在SQL文件里写好INSERT语句,论文附录还能附上,答辩时直接展示“我造了一批多维度测试数据验证接口”。
5.5 如何把项目包装成“亮点型毕设”
最后分享一个思路层面的心得:同样的功能,表达方式不同,分值完全不同。“实现了一个日记APP”和“实现了支持离线缓存、心情可视化的移动端个人知识管理工具”,描述的是几乎同样的代码,但后者明显更有吸引力。我写论文时把每个技术决策都包装成了“为什么这样选”的问题来组织,比如:
- 为什么用JWT而不是Session —— 无状态设计,支撑多端登录
- 为什么图片用UUID重命名 —— 防止文件名冲突与路径遍历攻击
- 为什么做逻辑删除 —— 数据可回溯,用户体验有保障
- 为什么统计用实时聚合 —— 保证数据一致性,架构简单
这套“设计决策 -> 技术方案 -> 收益说明”的叙事结构,不仅写论文好用,答辩的时候被问“你为什么这么设计”时,你也能从容回答,而不是只能说“大家都这么搞”。
做毕设最怕的不是功能多复杂,而是功能做完了说不清楚。把每个模块背后的“为什么”想透,你的项目就已经超过一半的人了。