简介:这是一份基于Android平台的计算机精品课程学习系统APP毕业设计论文,面向计算机相关专业毕业生,帮助解决毕业设计中的选题、系统设计与论文撰写难题。论文围绕在线学习平台展开,系统基于Spring Boot架构、Java语言和MySQL数据库开发,分管理员、教师、学生三个功能模块,支持视频讲座、互动式编程练习、课后作业、项目案例以及社区交流,学生可自主选择课程并根据评价工具及时检测学习成果。写作上,论文对课程学习管理流程进行了梳理,对功能性需求和非功能性需求做了分析,正文涵盖绪论、关键技术介绍、系统设计等核心章节,提供了较完整的毕业设计结构参考。压缩包内仅有1个docx文档,体积1.71MB,内容集中且便于阅读和二次编辑。目前已有56人学习/下载,适合正在完成计算机毕业设计、需要参考Android+Spring Boot项目论文成果的学习者。
1. 为什么这个“计算机精品课程学习系统”选 Spring Boot + Android
做这类选题最容易翻车的地方,不是功能太少,而是把 Android 端写成了 WebView 套壳:页面全用网页渲染,原生层只剩一个加载框。答辩时一问原生能力、离线缓存、推送、应用签名,基本答不上来。这个项目把 Android 原生 APP 与 Spring Boot 后台拆成两条独立链路,学生端走原生界面,教师和管理员共用后台管理界面,数据统一落到 MySQL,并用基于用户相似度的协同过滤算法做课程推荐。对需要完整前后端链路、又想展示推荐算法亮点的选题来说,这套结构比纯 Web 或纯小程序更耐问、更好扩展,也更容易在论文里画出清晰的架构图。
2. 三角色权限模型与数据库表设计:从用例图到 E-R 再到建表 SQL
2.1 管理端、教师端、学生端的权限边界怎么划
原论文把系统拆成管理员、教师、学生三个角色,这三个角色不是简单的前端页面区分,而是后台接口层的权限边界。管理员管理学生、教师、课程分类、视频课程、在线资源和系统公告;教师只管理自己负责的视频课程和在线资源;学生在 APP 端浏览课程、发帖、聊天、修改密码。权限边界一旦没设计好,最典型的问题就是学生调教师接口、教师调管理员接口,这在评审时属于硬伤。
角色权限在代码里落地时,我一般用一张用户表加一个 role 字段,而不是建三张独立的用户表。管理员表、学生表、教师表拆开存,登录后统一生成 token,接口层用拦截器校验角色。这样论文里可以画出管理员、教师、学生三张用例图,代码里又不需要写三套重复的登录逻辑。下面这张表是三个角色的用例对照,也是后面设计接口和数据表的依据。
| 角色 | 可用功能 | 操作范围 |
|---|---|---|
| 管理员 | 学生管理、教师管理、课程分类管理、视频课程管理、在线资源管理、交流论坛管理、系统公告 | 全量数据 |
| 教师 | 系统首页、个人中心、视频课程管理、在线资源管理 | 本人创建的数据 |
| 学生 | 首页浏览、视频课程、在线资源、交流论坛、我的发帖、聊天记录、修改密码 | 浏览内容与个人数据 |
这里有一个容易忽略的点:教师和学生的区别不只是“能不能上传课程”,而是数据归属。教师上传的视频课程要记录 teacher_id,查询时按当前登录用户过滤;学生只有阅读和评论权限。这个字段从一开始就要加进课程表,否则后期补权限过滤会到处改 SQL。
2.2 核心表结构:评论表、token 表与课程表的字段设计
原文的数据库设计里给出了视频课程评论表和管理员表、token 表的字段。这些表是后续写代码的地基,字段命名建议保持和论文一致,省得论文和源码对不上。评论表的核心字段是 refid(关联表 id)、userid、avatarurl、nickname、content、reply,管理员表和 token 表字段也已经在附录里定义好了。
在此基础上,还需要扩展学生表、教师表、视频课程表、课程分类表、交流论坛表。学生表一般包含学生账号、密码、姓名、性别、头像、电话;教师表包含教师账号、教师姓名、头像、联系方式;视频课程表包含课程名称、课程分类、简介、视频文件、封面、教师信息、发布时间、点赞数和收藏数。下面给出一份可以直接落到 MySQL 的建表语句。
CREATE TABLE `student` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `student_account` varchar(50) NOT NULL COMMENT '学生账号', `password` varchar(100) NOT NULL COMMENT '密码', `student_name` varchar(50) DEFAULT NULL COMMENT '姓名', `gender` varchar(10) DEFAULT NULL COMMENT '性别', `avatar` varchar(500) DEFAULT NULL COMMENT '头像', `phone` varchar(20) DEFAULT NULL COMMENT '电话', `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_account` (`student_account`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学生表'; CREATE TABLE `video_course` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `course_name` varchar(200) NOT NULL COMMENT '课程名称', `category_id` bigint DEFAULT NULL COMMENT '课程分类id', `teacher_id` bigint DEFAULT NULL COMMENT '上传教师id', `course_intro` text COMMENT '课程简介', `video_url` varchar(500) DEFAULT NULL COMMENT '视频地址', `cover` varchar(500) DEFAULT NULL COMMENT '封面图片', `publish_time` datetime DEFAULT NULL COMMENT '发布时间', `like_count` int DEFAULT '0' COMMENT '点赞数', `collect_count` int DEFAULT '0' COMMENT '收藏数', PRIMARY KEY (`id`), KEY `idx_category` (`category_id`), KEY `idx_teacher` (`teacher_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='视频课程表';建表时要注意两个细节。第一个是字符集必须用 utf8mb4,评论和帖子里的表情符号在 utf8 下会报错,系统支持聊天功能后这个问题几乎必现。第二个是点赞数、收藏数、评论数这类统计字段直接冗余在课程表里,查询首页列表时不用每次 count,性能好很多;代价是点赞、取消点赞时要同步更新两个地方。对于毕业设计的数据量,冗余带来的收益远大于维护成本。
2.3 表关联关系与两个常见设计误区
E-R 图里,管理员管理学生和教师是一对多,用户对视频课程的评论是多对一,在线资源与资源分类是多对一。代码里这些关系不要全用外键约束去强关联,MySQL 在数据量上来后外键会拖慢写入速度。常见做法是只在业务层维护关联 id,建表时用普通索引。上面的 SQL 里 category_id 和 teacher_id 都只建了 KEY,没有写 FOREIGN KEY,就是这个原因。
另一个常见误区是 token 表设计。原文的 token 表有 userid、username、tablename、role、token、expir 字段,这里 tablename 用来标记当前用户属于学生表还是教师表,因为三个角色不在同一张表里。登录时先按角色查对应表,校验通过后生成随机 token 存入数据库,后续请求带上 token,拦截器再反查用户信息。这种方案比直接把用户 id 放前端安全,也比纯 JWT 多了一层可控性,Redis 没配置时用它做服务端会话管理最稳妥。
3. Spring Boot 服务端实现:登录鉴权、文件上传与协同过滤推荐
3.1 后端分层结构与统一返回格式
Spring Boot 项目按 controller、service、mapper、entity 四层拆分,Controller 只做参数接收和结果返回,业务逻辑全部下沉到 Service。这样做的直接好处是论文里可以画分层架构图,答辩时也方便说清楚“推荐算法写在哪一层”。项目里统一用 Result 对象封装返回,格式是 code、msg、data,前端根据 code 判断请求是否成功,而不是去解析 HTTP 状态码。
在线资源、视频课程、论坛帖子的分页查询都用 MyBatis-Plus 的 Page 对象,服务端只暴露 pageNum 和 pageSize 两个参数。这里要注意 MySQL 的 LIMIT 写法,常见的坑是前端传第 1 页时 pageNum 从 0 还是从 1 开始,我的习惯是前端传 1,后端计算 offset = (pageNum - 1) * pageSize,避免出现“第一页和第二页数据重叠”的低级错误。
@Data public class Result<T> { private Integer code; private String msg; private T data; public static <T> Result<T> ok(T data) { Result<T> r = new Result<>(); r.setCode(200); r.setMsg("success"); r.setData(data); return r; } public static <T> Result<T> error(String msg) { Result<T> r = new Result<>(); r.setCode(500); r.setMsg(msg); return r; } }code 定为 200 和 500 是为了让 Android 端 Retrofit 的 onResponse 与 onFailure 语义对齐:网络层失败走 onFailure,业务失败靠 code 判断。不要把业务错误码和 HTTP 状态码混在一起,否则 Android 端会把“密码错误”当成 HTTP 500 处理,日志里全是红色异常,排错效率很低。
3.2 JWT 登录与角色鉴权实现
虽然数据库里有 token 表,建议把它作为会话记录,实际下发的凭证用 JWT。用户登录成功后,后端生成一个带 userId、role、过期时间的 JWT 返回给 Android 端,APP 每次请求在 Header 里带 Authorization。拦截器解析 JWT,拿到 userId 和 role 后放入 ThreadLocal,Service 层直接取当前用户,不需要每个接口都手动传 userid 参数。
@Component public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (token == null || token.isEmpty()) { response.setStatus(401); return false; } Claims claims = JwtUtil.parseToken(token.replace("Bearer ", "")); if (claims == null) { response.setStatus(401); return false; } UserContext.set(claims.get("userId", Long.class), claims.get("role", String.class)); return true; } }JWT 里不要放密码和手机号这类敏感信息,只放 userId 和 role。拦截器校验通过后,还需要一个角色注解或手动判断来控制接口权限。比如教师上传视频的接口要求 role 等于 teacher,管理员删除用户要求 role 等于 admin。这里有个经验:角色判断写在 Controller 方法第一行,比写在拦截器里更直观,因为不同接口允许的角色组合差异很大,写死在拦截器里反而难维护。
3.3 协同过滤推荐:基于用户相似度的评分计算
论文里点名的推荐算法是基于用户相似度的协同过滤。它的核心逻辑是:先建立“用户-课程”评分矩阵,然后计算当前用户与其他用户的相似度,找到最相似的几个用户,把他们喜欢但当前用户没学过的课程推荐出来。评分数据从哪里来?没有真实评分时,可以用“用户浏览课程 + 收藏 + 评论”加权构造评分。
public List<Long> recommend(Long userId, int topN) { // 1. 加载所有用户对课程的评分矩阵 Map<Long, Map<Long, Double>> userCourseRatings = ratingService.loadAllRatings(); Map<Long, Double> currentUserRatings = userCourseRatings.getOrDefault(userId, Collections.emptyMap()); // 2. 计算当前用户与其他用户的余弦相似度 Map<Long, Double> similarityMap = new HashMap<>(); for (Map.Entry<Long, Map<Long, Double>> entry : userCourseRatings.entrySet()) { Long otherUserId = entry.getKey(); if (otherUserId.equals(userId)) continue; double similarity = cosineSimilarity(currentUserRatings, entry.getValue()); similarityMap.put(otherUserId, similarity); } // 3. 取相似度最高的 K 个用户,统计他们学过而当前用户没学过的课程 List<Map.Entry<Long, Double>> sorted = similarityMap.entrySet().stream() .sorted((a, b) -> Double.compare(b.getValue(), a.getValue())) .limit(10) .collect(Collectors.toList()); Map<Long, Double> scoreMap = new HashMap<>(); for (Map.Entry<Long, Double> entry : sorted) { Map<Long, Double> otherRatings = userCourseRatings.get(entry.getKey()); for (Map.Entry<Long, Double> courseRating : otherRatings.entrySet()) { if (currentUserRatings.containsKey(courseRating.getKey())) continue; scoreMap.merge(courseRating.getKey(), courseRating.getValue() * entry.getValue(), Double::sum); } } // 4. 按加权分排序,返回 TopN return scoreMap.entrySet().stream() .sorted((a, b) -> Double.compare(b.getValue(), a.getValue())) .limit(topN) .map(Map.Entry::getKey) .collect(Collectors.toList()); }相似度的计算用余弦公式,两用户在共同评分课程上的向量夹角越小,相似度越高。新用户没有历史行为,协同过滤直接失效,此时兜底策略是按课程收藏数或最近发布时间取热门课程。这一步很关键,因为答辩时老师一定会问“新用户怎么办”,能答出热门兜底比只说算法原理更显工程经验。另外,评分矩阵是实时从数据库加载的,数据量大后要改成定时离线统计,毕业设计里数据量小,实时算就行。
3.4 视频文件上传与静态资源映射
视频课程管理里的视频和封面不能直接存数据库,通常是上传到服务器磁盘,数据库只存访问路径。Spring Boot 接收 MultipartFile,文件写到配置好的 upload 目录,然后返回可访问的 URL。这里有一个很容易踩的坑:视频文件默认不能超过 1MB,Spring Boot 的 spring.servlet.multipart.max-file-size 必须显式调大,否则上传 50MB 的课程视频时会在前端看到“上传失败”但后端日志没有任何异常。
spring: servlet: multipart: max-file-size: 200MB max-request-size: 200MB mvc: static-path-pattern: /upload/**静态资源映射用 static-path-pattern 把 /upload/** 指向本地磁盘目录,视频播放时 Android 端拿到的 video_url 直接是http://ip:8080/upload/xxx.mp4,浏览器和播放器都能直接访问。视频格式建议统一转成 MP4 H.264,因为 Android 原生 MediaPlayer 对部分 MKV、AVI 格式支持不好,转码问题写在论文里也可以作为一个“系统优化”章节的素材。
4. Android 端实现:从网络层到界面层的完整调用链
4.1 Retrofit + OkHttp 网络层封装
Android 端采用 Retrofit 做 HTTP 请求,OkHttp 做底层拦截器。网络层要做两件事:统一在 Header 里加 Authorization token,以及统一解析 Result 结构。登录成功后 token 存到 SharedPreferences,后续每个请求都带上。不封装的话,每个接口都要写一遍 token 拼接,代码里到处是重复的 Header 代码。
object ServiceCreator { private const val BASE_URL = "http://192.168.1.100:8080/api/" private val okHttpClient = OkHttpClient.Builder() .addInterceptor { chain -> val token = SharedPrefsUtil.getToken() val request = chain.request().newBuilder() .addHeader("Authorization", "Bearer $token") .build() chain.proceed(request) } .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .build() private val retrofit = Retrofit.Builder() .baseUrl(BASE_URL) .client(okHttpClient) .addConverterFactory(GsonConverterFactory.create()) .build() fun <T> create(service: Class<T>): T = retrofit.create(service) }readTimeout 设为 30 秒是因为视频课程列表接口里包含封面图 URL,弱网环境下图片加载会拖慢整体请求,网络层超时太短会导致列表页频繁失败。BASE_URL 在调试时指向局域网 IP,连不上时先检查手机和电脑是否在同一网段,这是 Android 开发里最常见的联调问题。模拟器访问宿主机要用 10.0.2.2,真机要用电脑的局域网 IP,两个地址写错都会表现为“请求超时”。
4.2 底部导航与 Fragment 架构
APP 端功能集中在五个页面:首页、视频课程、在线资源、交流论坛、我的。底部导航用 BottomNavigationView 加 Fragment 实现,每个 tab 对应一个 Fragment。要注意 Fragment 切换不能每次都重新创建,否则用户从“交流论坛”切回“首页”时,列表会重新加载并回到顶部,体验非常差。
private void switchFragment(int position) { FragmentTransaction transaction = getSupportFragmentManager().beginTransaction(); hide(currentFragment); Fragment target = fragments.get(position); if (target.isAdded()) { transaction.show(target); } else { transaction.add(R.id.container, target); } transaction.commit(); currentFragment = target; }这里用 add 和 hide 组合而不是 replace,理由是 replace 会销毁 Fragment 视图,再次切换时重新走 onCreateView,既丢状态又浪费性能。add + hide 只是隐藏视图,Fragment 的成员变量和列表滚动位置都还在。这个写法在 Android 开发里是标准做法,写在论文里也体现得出对 Fragment 生命周期的理解。
4.3 视频课程列表、学习进度与播放器实现
4.3.1 列表加载与进度条展示
视频课程列表用 RecyclerView 展示,每项显示封面、课程名称、分类、教师名和收藏数。学习进度条这里有两种实现层级:列表项里的进度条显示该课程的学习百分比,进入播放页后保存播放位置,下次进入从上次位置继续播放。进度的存取用 SharedPreferences 就行,key 用 courseId,value 用毫秒。
public class CourseListAdapter extends RecyclerView.Adapter<CourseListAdapter.ViewHolder> { private List<Course> courseList; @Override public void onBindViewHolder(ViewHolder holder, int position) { Course course = courseList.get(position); holder.courseName.setText(course.getCourseName()); holder.teacherName.setText(course.getTeacherName()); // 读取本地学习进度,更新进度条 long positionMs = SharedPrefsUtil.getPlayPosition(course.getId()); int percent = (int) (positionMs * 100 / course.getDuration()); holder.progressBar.setProgress(percent); ImageLoader.load(holder.cover, course.getCover()); } }进度条控件用 ProgressBar 的 setProgress 方法更新。duration 要在播放器加载视频后才能拿到,所以课程表里最好冗余一个 duration 字段,否则列表页没法在不知道总时长的情况下计算百分比。课程上传时后端用 FFmpeg 或播放器 SDK 解析出时长存库,列表页直接读,这个字段在设计表时就应该预留。
4.3.2 播放器选型与生命周期处理
播放器不建议用系统自带的 VideoView,它封装得太死了,横竖屏切换和进度保存都要自己处理,反而更麻烦。我一般用 Google 的 Media3 ExoPlayer,支持 HLS、自适应码率、倍速播放,而且 API 比 VideoView 灵活。播放页在 onPause 里暂停并保存进度,在 onDestroy 里释放播放器,否则会导致音频继续播放、Activity 泄漏。
@Override public void onPause() { super.onPause(); if (player != null) { long currentPosition = player.getCurrentPosition(); SharedPrefsUtil.savePlayPosition(courseId, currentPosition); player.pause(); } }这里有一个实际的坑:如果项目里同时用 Android 原生播放器和 WebView 播放 H5 视频,onPause 时 WebView 里的视频不会自动暂停,会出现“页面退出了声音还在响”的情况。解决方法是 onPause 里遍历 WebView 执行 evaluateJavascript 调用页面的暂停方法。毕业设计只用原生播放器就简单很多,但这个问题可以作为论文里系统测试阶段的已知问题写进去。
4.4 我的发帖、聊天记录与本地持久化
交流论坛里的“我的发帖”直接查后端接口,按当前 userId 过滤帖子列表。聊天记录的实现方案要看项目时间:时间紧张就用轮询,每隔 3 秒调一次“获取新消息”接口;时间充裕就引入 WebSocket。轮询的优点是后端不用额外引入依赖,缺点是耗电和费流量,但局域网内测试基本无感。
public void fetchNewMessages() { apiClient.getMessages(userId, lastMessageId) .enqueue(new Callback<List<Message>>() { @Override public void onResponse(Call<List<Message>> call, Response<List<Message>> response) { if (response.isSuccessful()) { List<Message> messages = response.body(); if (messages != null && !messages.isEmpty()) { adapter.addMessages(messages); lastMessageId = messages.get(messages.size() - 1).getId(); } handler.postDelayed(fetchRunnable, 3000); } } }); }轮询接口带 lastMessageId,只拉取比当前最大消息 id 更大的数据,避免每次全量拉聊天记录造成列表闪烁。聊天记录的本地缓存用 Room 或直接存在 SQLite,离线时也能看历史记录。这里和视频进度一样属于“本地能力”的展示点,答辩时提一句“Room 存消息、SharedPreferences 存进度”比只说调了后端接口要扎实得多。
5. 验证与上线:功能测试、抓包调试、签名与发布
5.1 功能测试用例设计与回归
这个系统的测试要覆盖登录、权限、课程管理、评论、推荐、聊天六条主链路。测试时不能只测正常路径,登录失败、越权访问、空列表这些异常场景才是功能测试的拿分点。下面是一张可以直接用在论文测试章节的用例表。
| 用例编号 | 测试场景 | 操作步骤 | 预期结果 |
|---|---|---|---|
| TC-001 | 学生登录成功 | 输入正确账号密码 | 进入 APP 首页 |
| TC-002 | 学生登录失败 | 输入错误密码 | 提示密码错误 |
| TC-003 | 教师越权访问 | 用教师 token 请求管理员接口 | 返回 403 |
| TC-004 | 课程分页加载 | 下拉刷新并滑动列表 | 第二页数据正常追加 |
| TC-005 | 收藏课程 | 点击收藏按钮 | 收藏数加一,可取消 |
| TC-006 | 评论课程 | 输入内容提交 | 评论区即时显示 |
越权访问测试在 Postman 里做比在 APP 里做更方便,把 Authorization 换成不同角色的 token 再请求接口,返回 403 就是权限拦截生效。回归测试我用 Appium 写了一个最简单的脚本,启动 APP、输入账号密码、点击登录按钮,断言首页标题出现。跑通这一条冒烟用例,能保证每次改完服务端接口后 APP 没有崩在最前面。
5.2 抓包定位联调问题
APP 端联调时最头疼的问题是接口请求失败但代码看着没问题。此时用抓包工具看实际请求和响应,比反复打 Log 高效得多。Windows 上开 Charles 或 mitmproxy,手机连同一个局域网,代理指到电脑端口,就能看到 APP 发出的每一个请求、Header 里的 token,以及后端返回的完整 JSON 响应。
Android 7.0 之后有个坑:默认不信任用户安装的 CA 证书,抓包工具会提示 SSL 握手失败。调试阶段的解决方案是在 res/xml 下放一个 network_security_config.xml,设置 debug 包信任用户证书:
<network-security-config> <base-config cleartextTrafficPermitted="true"> <trust-anchors> <certificates src="system"/> <certificates src="user"/> </trust-anchors> </base-config> </network-security-config>上面这段配置同时允许了 HTTP 明文请求。开发环境后端是http://192.168.x.x:8080,Android 9 后默认禁止明文流量,不配置的话网络请求会被直接拦掉,表现就是“接口返回成功但数据是空的”。清掉缓存重试、确认代理端口、检查手机和电脑同网段,这三个动作能解决八成联调问题。
5.3 签名、混淆与发布前检查
发布 APP 的第一步是生成签名文件。用 Android Studio 的 Build 菜单生成 keystore 后,要记录 SHA1 和 SHA256 指纹。获取指纹用的是 keytool 命令行,这个值在申请地图 SDK、微信分享等第三方平台时都要用。如果发布版和调试版签名不一致,就会出现“升级安装失败”的问题。
keytool -list -v -keystore app-release.jks -alias course-app上线前还要检查 release 包是否开启了混淆。开启混淆的 build.gradle 配置是minifyEnabled true,配合 proguard-rules.pro 保留 Retrofit 的注解和实体类,否则发布版很容易出现“JSON 解析失败”或“接口找不到方法”的崩溃。最后用./gradlew assembleRelease打正式包,装到一台新手机上把登录、看视频、发帖、退出登录跑一遍,确认没有问题再提交应用商店审核。
发布到应用商店后,还有一个容易被忽略的点:Android 的桌面图标和字体大小在不同厂商 ROM 上表现不一样,图标要至少提供 48dp 的 mipmap 全套尺寸,启动页面不要用绝对像素定位控件。这些细节不决定功能对错,但影响商店审核的通过率和用户的第一印象,对“计算机精品课程学习系统”这种面向学生的 APP 来说,界面观感往往比功能清单更能打动评审老师。
本文还有配套的精品资源,点击获取