
简介这是一套面向Java与移动开发学习者的原生高仿短视频APP双端源码覆盖Android与iOS两端适合希望深入理解短视频应用完整实现、提升音视频处理与跨平台开发能力的中高级开发者。压缩包共1127个文件约63.2MB以258个java源码、317个xml布局、417个png图片资源为主另含39个so库、23个jar包及gradle构建脚本完整呈现工程结构与依赖配置。项目涉及Android SDK、Activity与Fragment、MediaCodec、MediaPlayer、OpenGL ES、FFmpeg、AVFoundation、Objective-C、PHP 5.6、MySQL 5.5与ThinkPHP框架并包含伪静态URL重写等后端实践。已有776人学习下载。读者可借此梳理双端音视频编解码、UI布局与服务器接口通信的完整链路对照源码理解设计模式与最佳实践是研究短视频类应用架构的实用参考。1. 原生Java高仿短视频APP双端源码一套能跑通的双端工程到底长什么样短视频赛道卷到今天真正稀缺的不是创意而是能落地的工程骨架。很多人搜「原生Java高仿短视频APP双端源码」本质诉求就一个不想从零搭架子想拿一套结构清晰、能编译、能改、能上手的双端工程把短视频的核心链路——登录、刷视频、点赞、评论、关注、私信——先跑通再谈差异化。原生Java意味着服务端不套 Spring Cloud 全家桶的壳客户端不依赖跨端框架Android 用 Java 写后端用 Java SE/EE 写中间靠 HTTP JSON 通信。这套组合的好处是链路透明、调试直观、面试时能讲清楚每一层在干什么坏处是轮子得自己造性能优化空间全靠手写。适合谁适合想拿短视频业务练手 Java 全栈的开发者、课程设计需要完整案例的学生、以及准备用「短视频 双端」讲项目经验的求职者。下面按「架构怎么拆 → 服务端怎么写 → 客户端怎么接 → 坑在哪 → 怎么验证」的顺序把这条路走一遍。2. 双端架构怎么拆从接口契约到模块边界2.1 为什么先定接口契约再写代码短视频 APP 的双端协作翻车最多的地方不是技术难度而是接口对不上。服务端返回{code:0,data:{...}}客户端按{status:ok,result:{...}}解析联调时两边互相甩锅。我一般会在动手前先把接口契约写成一张表字段名、类型、是否必填、示例值全部定死客户端和服务端各自照着这张表写谁改谁负责同步。核心接口按业务域分四组用户域注册、登录、资料、关注、内容域视频列表、详情、发布、点赞、评论、社交域私信、通知、基础域文件上传、配置下发。每组接口的路径用统一前缀比如/api/user/、/api/video/版本号放在路径里/api/v1/后续改协议不至于把老客户端打死。接口方法路径关键参数返回要点登录POST/api/v1/user/loginphone, passwordtoken, userId, nickname视频流GET/api/v1/video/feedpage, size, userIdlist[{videoId, coverUrl, playUrl, author}]点赞POST/api/v1/video/likevideoId, userIdlikeCount, isLiked评论POST/api/v1/video/commentvideoId, userId, contentcommentId, createTime关注POST/api/v1/user/followfromUserId, toUserIdfollowState这张表不是文档摆设它是后面所有代码的源头。服务端的 Controller 方法签名、客户端的 Retrofit 接口定义都从这张表推导出来改一处两边同步。2.2 服务端分层Controller / Service / DAO 各管什么原生 Java 服务端不引入重型框架时最常见的做法是用轻量 HTTP 库比如内置com.sun.net.httpserver或引入 Jetty 的嵌入式模式做接入层业务逻辑自己分层。分层不是为了好看是为了出问题时能快速定位是哪一层的锅。Controller 层只做三件事解析请求参数、调用 Service、封装统一响应。不写业务判断不直接碰数据库。Service 层承载业务规则比如「点赞前先查是否已点赞」「评论内容不能为空且长度不超过 200」「关注自己要被拦截」。DAO 层只负责 SQL 执行和结果映射不掺业务逻辑。// Controller 层示例统一响应封装 参数校验入口 public class VideoController { private final VideoService videoService new VideoServiceImpl(); // 处理 GET /api/v1/video/feed?page1size10userId1001 public ApiResponse feed(MapString, String params) { // 参数解析与默认值page 从 1 开始size 上限 50 防止拖库 int page Integer.parseInt(params.getOrDefault(page, 1)); int size Math.min(Integer.parseInt(params.getOrDefault(size, 10)), 50); long userId Long.parseLong(params.getOrDefault(userId, 0)); // 只做转发业务逻辑在 Service ListVideoVO list videoService.getFeed(userId, page, size); return ApiResponse.ok(list); } }这段代码的关键点在于参数默认值和上限控制放在 Controller业务查询放在 Service响应格式统一走ApiResponse。size上限 50 是血泪经验不设上限的话客户端传个size100000数据库直接被打穿。userId默认 0 表示未登录游客Service 层根据这个值决定是否返回个性化推荐还是纯热门排序。2.3 客户端模块划分Android 端的最小骨架Android 端用原生 Java 写不套 Compose、不套 Flutter模块按功能切networkRetrofit OkHttp、model数据实体、uiActivity Adapter、player视频播放封装、storage本地缓存。每个模块只暴露必要接口模块之间不互相直接引用实现类。网络层用 Retrofit 定义接口和上面的接口契约表一一对应// Android 端 Retrofit 接口定义字段名与服务端契约严格一致 public interface ApiService { POST(api/v1/user/login) CallApiResponseLoginResult login(Body LoginRequest request); GET(api/v1/video/feed) CallApiResponseListVideoVO feed( Query(page) int page, Query(size) int size, Query(userId) long userId ); POST(api/v1/video/like) CallApiResponseLikeResult like(Body LikeRequest request); }ApiResponseT用泛型统一包裹code非 0 时统一走错误处理。这里有个容易忽略的点Retrofit 的Call是异步的回调在子线程更新 UI 必须切回主线程。我一般封装一层ApiCallbackT在onResponse里判断response.isSuccessful()和body.getCode()两层都过了才回调业务层否则统一走onError。模块边界定清楚之后后面加功能就是往对应模块里塞代码不会出现「改一个点赞功能结果把登录搞崩了」的情况。3. 服务端核心链路视频流、点赞、评论怎么写3.1 视频流分页查询从 SQL 到缓存视频流是短视频 APP 最核心也最容易出性能问题的接口。用户每次下拉刷新都要拉一页新数据QPS 峰值可能是其他接口的几十倍。原生 Java 环境下没有 Redis 也能做但做了缓存和没做缓存体验差距是数量级的。先看 DAO 层的 SQL。视频流按发布时间倒序游标分页比LIMIT offset更适合短视频场景因为 offset 越大扫描行数越多翻到第 100 页时数据库已经在骂人了。// DAO 层游标分页查询lastVideoId 为上一页最后一条的 ID public ListVideoVO queryFeed(long lastVideoId, int size) { String sql SELECT v.id, v.title, v.cover_url, v.play_url, v.like_count, u.id AS author_id, u.nickname, u.avatar FROM t_video v JOIN t_user u ON v.author_id u.id WHERE v.status 1 AND v.id ? ORDER BY v.id DESC LIMIT ?; // lastVideoId 首次传 Long.MAX_VALUE后续传上一页最后一条的 id return jdbcTemplate.query(sql, new Object[]{lastVideoId, size}, new VideoRowMapper()); }WHERE v.id ?配合ORDER BY v.id DESC走主键索引每页扫描行数恒定不会随翻页深度增加。status 1过滤已删除视频。size由 Controller 层限制上限DAO 不再重复校验。缓存层用ConcurrentHashMap做本地一级缓存key 是feed:lastVideoId:sizevalue 是序列化后的列表过期时间 30 秒。为什么是 30 秒短视频内容更新频率高缓存太久用户刷不到新视频太短又没意义。30 秒是实测下来比较平衡的值。缓存命中时直接返回未命中才走数据库回填缓存。注意本地缓存只适合单机部署。如果服务端多实例本地缓存会导致不同实例返回不同数据这时候要么上集中式缓存要么把缓存关掉直接查库。3.2 点赞的幂等与计数一致性点赞接口看起来简单实际上是最容易出数据不一致的地方。用户快速双击、网络重试、客户端 bug 重复提交都会导致同一条视频被同一个人点赞多次。幂等必须做。方案是数据库层加唯一索引UNIQUE KEY uk_user_video (user_id, video_id)插入时用INSERT IGNORE或捕获唯一键冲突异常。这样即使 Service 层判断漏了数据库也能兜底。// Service 层点赞幂等 计数更新 public LikeResult like(long userId, long videoId) { // 先查是否已点赞减少无效插入 boolean exists likeDao.exists(userId, videoId); if (exists) { return new LikeResult(likeDao.countByVideo(videoId), true); } // 插入点赞记录唯一索引兜底 boolean inserted likeDao.insertIgnore(userId, videoId); if (inserted) { // 只有真正插入成功才更新计数避免重复加 videoDao.incrementLikeCount(videoId); } return new LikeResult(likeDao.countByVideo(videoId), true); }insertIgnore返回受影响行数0 表示已存在1 表示新插入。只有新插入才incrementLikeCount保证计数和记录一致。countByVideo从点赞记录表实时统计不依赖t_video.like_count字段因为那个字段在高并发下可能滞后。如果对性能要求高可以定时任务校准like_count但查询时以记录表为准。取消点赞同理先删记录删成功了才减计数。这里有个坑如果先减计数再删记录删记录失败时计数就少了数据永久不一致。顺序不能反。3.3 评论列表与二级回复的树形结构评论比点赞复杂因为有层级。一级评论挂在视频下二级回复挂在一级评论下。常见做法是两张表t_comment存一级评论t_reply存二级回复或者一张表用parent_id自关联。我一般用一张表加parent_id查询时分两步先查该视频下所有parent_id 0的一级评论再根据一级评论 ID 批量查二级回复在内存里组装成树。这样比递归查库少很多次 IO。// Service 层评论树组装 public ListCommentVO getCommentTree(long videoId, int page, int size) { // 第一步分页查一级评论 ListCommentVO roots commentDao.queryRoots(videoId, page, size); if (roots.isEmpty()) return roots; // 第二步批量查这些一级评论下的二级回复 ListLong rootIds roots.stream().map(CommentVO::getId).collect(Collectors.toList()); MapLong, ListCommentVO replyMap commentDao.queryReplies(rootIds) .stream().collect(Collectors.groupingBy(CommentVO::getParentId)); // 第三步内存组装 for (CommentVO root : roots) { root.setReplies(replyMap.getOrDefault(root.getId(), Collections.emptyList())); } return roots; }queryReplies用WHERE parent_id IN (?, ?, ...)一次查完避免 N1。二级回复默认只返回前 3 条客户端点「查看更多」再单独拉。这个策略是跟主流短视频 APP 对齐的首屏加载快用户也不会一次看几百条回复。提示IN子句的参数个数要控制MySQL 对IN的元素数量有上限默认受max_allowed_packet影响一级评论一页 20 条以内是安全的超过 100 条建议分批查。4. 客户端关键实现播放器封装与列表复用4.1 视频播放器选型与生命周期管理Android 原生播放视频可选MediaPlayer、ExoPlayer、IjkPlayer。MediaPlayer是系统自带不用引依赖但格式支持有限、缓冲策略不灵活。ExoPlayer是 Google 官方扩展性好支持 DASH/HLS但包体积大。IjkPlayer是 B 站开源的基于 FFmpeg格式支持最全但维护活跃度下降。如果只是课程设计或练手项目我建议先用MediaPlayer把链路跑通因为不引额外依赖编译快、调试直观。等核心功能稳定了再换ExoPlayer做体验优化。播放器封装的核心是生命周期。短视频列表里每个 item 都可能是一个播放器实例如果每个都创建MediaPlayer内存直接爆炸。正确做法是全局只维护一个播放器实例列表滑动时把播放器 attach 到当前可见的 item 上。// 播放器管理器全局单例跟随列表可见性切换 public class PlayerManager { private static PlayerManager instance; private MediaPlayer mediaPlayer; private View currentView; // 当前绑定的 SurfaceView public static synchronized PlayerManager getInstance() { if (instance null) { instance new PlayerManager(); } return instance; } // 列表滚动时调用position 为当前可见 item 的位置 public void onScroll(int position, ListVideoVO data, RecyclerView recyclerView) { // 找到第一个完全可见的 item LinearLayoutManager lm (LinearLayoutManager) recyclerView.getLayoutManager(); int firstVisible lm.findFirstCompletelyVisibleItemPosition(); if (firstVisible RecyclerView.NO_POSITION) return; // 如果可见 item 变了切换播放源 if (currentView ! null currentView.getTag() ! null (int) currentView.getTag() firstVisible) { return; // 还是同一个不处理 } playVideo(data.get(firstVisible), recyclerView); } }findFirstCompletelyVisibleItemPosition保证只有完全可见的 item 才播放滑到一半的不播避免多个播放器同时出声。currentView.getTag()存的是 item 位置用来判断是否需要切换。切换时先reset()再setDataSource()不要直接换 URL否则MediaPlayer状态机容易报IllegalStateException。4.2 RecyclerView 复用导致的错位问题RecyclerView 的 ViewHolder 复用是性能利器但在短视频场景下也是错位重灾区。典型现象用户快速滑动视频 A 的封面显示在视频 B 的位置上或者点赞按钮的状态串了。根因是 ViewHolder 被复用时没有把新数据完整绑定上去。比如onBindViewHolder里只更新了标题和封面忘了重置点赞图标复用的 ViewHolder 还保留着上一条数据的点赞状态。// Adapter 的 onBindViewHolder每个字段都必须显式绑定 Override public void onBindViewHolder(VideoHolder holder, int position) { VideoVO item dataList.get(position); // 文本和图片必须每次绑定 holder.title.setText(item.getTitle()); Glide.with(holder.itemView.getContext()) .load(item.getCoverUrl()) .into(holder.cover); // 状态类字段必须重置不能依赖 ViewHolder 的初始状态 holder.likeIcon.setImageResource( item.isLiked() ? R.drawable.ic_liked : R.drawable.ic_like); holder.likeCount.setText(formatCount(item.getLikeCount())); // 播放器绑定只有当前可见 item 才 attach holder.itemView.setTag(position); if (position PlayerManager.getInstance().getCurrentPosition()) { PlayerManager.getInstance().attach(holder.surfaceView); } else { holder.surfaceView.setVisibility(View.GONE); } }关键原则onBindViewHolder里每一个可能变化的字段都要显式赋值不能假设 ViewHolder 是「干净」的。setTag(position)是为了在播放器管理器里判断当前 item 位置。surfaceView的可见性也要跟着切不可见的 item 把 SurfaceView 隐藏掉减少渲染开销。注意Glide 加载图片时如果 Activity 已销毁回调会崩。用Glide.with(holder.itemView.getContext())而不是Glide.with(activity)让 Glide 跟随 itemView 的生命周期。4.3 网络层统一错误处理与 Token 刷新客户端和服务端联调时最烦的不是接口报错而是报错信息不统一。有的接口返回{code:401,msg:token expired}有的直接 HTTP 401 空 body。客户端要写一堆 if-else 去猜。解法是在 OkHttp 层加一个 Interceptor统一拦截响应把 HTTP 状态码和业务 code 归一化。Token 过期时自动刷新一次刷新失败才跳登录页。// OkHttp 拦截器统一错误处理 Token 自动刷新 public class AuthInterceptor implements Interceptor { Override public Response intercept(Chain chain) throws IOException { Request request chain.request(); // 给所有请求带上 token String token TokenStore.getToken(); if (token ! null) { request request.newBuilder() .header(Authorization, Bearer token) .build(); } Response response chain.proceed(request); // HTTP 401 或业务 code 401 都触发刷新 if (response.code() 401) { synchronized (this) { String newToken TokenStore.refreshToken(); if (newToken ! null) { // 用新 token 重试一次 Request newRequest request.newBuilder() .header(Authorization, Bearer newToken) .build(); return chain.proceed(newRequest); } } } return response; } }synchronized (this)保证多个并发请求同时 401 时只刷新一次 token其他请求等刷新完拿新 token 重试。如果不加锁10 个请求同时刷新服务端会收到 10 次刷新请求可能触发风控。TokenStore用SharedPreferences存 token刷新接口单独走一个不带拦截器的 OkHttpClient避免死循环。5. 避坑与排查双端联调最容易翻车的 5 个点5.1 视频上传后播放地址 404现象客户端上传视频成功服务端返回了playUrl但播放器加载时报 404。原因服务端把文件存到了本地磁盘返回的 URL 是http://localhost:8080/upload/xxx.mp4客户端在真机上访问localhost指向的是手机自己不是服务器。或者文件存了但静态资源映射没配HTTP 服务器不认识/upload/路径。解决返回的 URL 必须是客户端能访问到的地址开发阶段用局域网 IP比如http://192.168.1.100:8080/upload/xxx.mp4。静态资源映射在 HTTP 服务器启动时注册把/upload/路径映射到磁盘目录。测试时先用浏览器直接打开这个 URL能播放再让客户端接。5.2 点赞数忽大忽小现象点赞后计数有时加 1有时加 2刷新后又变回去。原因incrementLikeCount和insertIgnore不在同一个事务里插入成功但计数更新失败时下次查询从记录表统计又对了但t_video.like_count字段已经脏了。或者客户端快速双击两次请求都通过了 Service 层的exists检查并发间隙都执行了插入唯一索引只拦住了一条但计数加了两次。解决插入和计数更新放在同一个数据库事务里用Transactional或手动setAutoCommit(false)。计数更新用UPDATE t_video SET like_count like_count 1 WHERE id ?依赖数据库行锁保证原子性。客户端侧加防抖300ms 内的重复点击直接忽略。5.3 视频列表滑动卡顿现象列表滑动时掉帧快速滑动时白屏。原因onBindViewHolder里做了耗时操作比如同步读数据库、解码图片、创建 MediaPlayer。或者 Glide 没有配置缓存策略每次滑动都重新下载图片。解决onBindViewHolder里只做轻量绑定图片加载交给 Glide 异步视频播放器全局单例复用。Glide 配置diskCacheStrategy(DiskCacheStrategy.ALL)和skipMemoryCache(false)让封面图走内存和磁盘缓存。列表setItemViewCacheSize(10)增大缓存池减少onBindViewHolder调用频率。5.4 Token 过期后所有请求同时失败现象token 过期后首页同时发起的 5 个请求全部返回 401客户端连续跳了 5 次登录页。原因拦截器里每个 401 响应都独立触发刷新和跳转没有做并发控制。解决拦截器里加锁只允许一个请求执行刷新其他请求等待。刷新成功后用新 token 重试原请求。跳转登录页用AtomicBoolean标记只跳一次。刷新失败时清空本地 token统一跳登录。5.5 服务端返回中文乱码现象客户端收到的 JSON 里中文显示为??????或乱码。原因HTTP 响应头没有设置Content-Type: application/json; charsetUTF-8或者服务端写响应时用了平台默认编码。解决服务端所有响应统一设置Content-Type头包含charsetUTF-8。写 JSON 时用response.getOutputStream().write(json.getBytes(StandardCharsets.UTF_8))不要用new String(bytes)不带编码。客户端 OkHttp 解析时response.body().string()默认按 UTF-8 解一般不用改但服务端必须发对。6. 怎么验证这套双端源码值不值得投入拿到或写完一套双端源码后别急着加功能先做三件事验证它是不是「活的」。第一跑通最小闭环。服务端启动数据库建表客户端装到真机注册一个账号发一条视频用另一个账号刷到它、点赞、评论、关注。这条链路走通说明核心架构没大问题。走不通先查网络配置和接口契约别急着改代码。第二压一下视频流接口。用ab或wrk对/api/v1/video/feed发 1000 个请求看平均响应时间和错误率。原生 Java 没优化的情况下单机 QPS 到 200 左右算正常低于 50 说明数据库查询或连接池有问题。重点看慢查询日志feed接口的 SQL 必须走索引。第三模拟弱网。用 Android 模拟器的网络限速功能把延迟调到 500ms、丢包 10%刷视频看会不会崩。弱网下最容易暴露的问题是超时没处理、重试逻辑缺失、加载状态没展示。如果弱网下直接白屏或闪退这套代码的健壮性就不够。# 用 wrk 压测视频流接口4 线程 100 连接持续 30 秒 wrk -t4 -c100 -d30s http://127.0.0.1:8080/api/v1/video/feed?page1size10userId0 # 关注三个指标Requests/sec、Latency 分布、Non-2xx responses # 如果 Non-2xx 超过 1%先查服务端日志有没有异常堆栈压测时注意userId0走的是游客逻辑和登录用户走的推荐逻辑可能不同两个都要压。size10是正常值再压一组size50看上限保护有没有生效。这套双端源码的价值不在于功能多全而在于链路是否透明、分层是否清晰、坑是否被填过。我自己的习惯是拿到任何一套源码先看它的错误处理和数据一致性方案这两块扎实后面加功能才放心。如果这两块是糊的功能再多也是空中楼阁。希望帮到你。本文还有配套的精品资源点击获取