简介:这份源码面向移动应用开发者与小型技术团队,提供安卓与苹果双端的在线视频聚合APP工程代码,可用于搭建影视类应用,也适合作为跨平台开发与完整业务闭环的学习样本。资源以zip压缩包分发,整体约42.5MB,内含前端工程、后端服务代码、数据库脚本以及使用与部署教程,可对照目录结构逐模块阅读。后台功能相对完整,覆盖用户与内容管理、支付接口集成、播放与数据处理、数据统计等环节,并实现分销推广中的上下级关系绑定与佣金结算、卡密的生成验证与用户绑定、会员权益的购买转赠等业务链路,对研究虚拟商品售卖和用户增长路径有实际参考价值。附带教程说明了部署流程与二次开发切入点,便于理解架构后修改界面与逻辑。目前已有1177人学习下载,适合具备移动端与后端基础、希望掌握影视APP完整实现思路的开发者参考。
1. 从一份双端影视源码开始:它能跑出什么样的 App
某开发者从群里拿到一份标着「双端」的影视 APP 源码,第一反应都是同一个:能不能直接把名字一改就上架。真正解压打开之后会发现,这份影视 APP 源码给的不是一个空壳,而是一整套已经跑通的链路——首页推荐、分类筛选、搜索联想、详情页选集、播放器全屏手势、收藏与观看历史,Android 和 iOS 共用同一套业务代码,只在播放内核和少量系统能力上走原生通道。它适合两类人:一类是有现成内容接口、想快速搭一个可运营客户端的团队;另一类是拿它当 Flutter 实战样本,想看一个真实的中型项目怎么拆网络层、状态管理和播放器封装的个人开发者。下面按我实际拆这份源码的顺序来讲,从工程结构一路讲到能编译、能改、能接自己接口。
2. 技术栈与工程结构:一套代码怎么同时喂饱 Android 和 iOS
2.1 技术选型:跨端框架和原生能力的交界在哪
这类双端影视项目绝大多数走 Flutter,原因不复杂:影视 App 的界面密度极高,首页多楼层、分类九宫格、详情页横滑选集、播放页浮层控制条,用两套原生写一遍,改一次 UI 要动两次代码,维护成本直接翻倍。Flutter 的自绘渲染在这类「重列表、重手势、轻系统控件」的场景里优势明显,滑动手感和一致性都比 WebView 套壳稳。
代价也很清楚,主要集中在播放器。视频解码这种吃硬件的活,Flutter 层自己做不划算,合格的做法是把解码下沉到平台侧:Android 用 ExoPlayer(现在叫 Media3),iOS 用 AVPlayer,Dart 层只负责控制条、手势、进度同步。这份源码就是按这个思路分的层,lib/modules/player里是 UI 和状态,真正的解码能力通过插件或 MethodChannel 调下去。
我把三种常见方案摆一起对比,选型时心里有个数:
| 方案 | 优点 | 代价 | 适用场景 |
|---|---|---|---|
| Flutter 单代码库 + 原生播放内核 | 一套 UI 双端复用,播放兼容性好 | 包体积偏大,插件版本要盯 | 影视、短视频、直播类 |
| 双端原生分别开发 | 系统能力最贴,性能上限高 | 人力翻倍,UI 容易走样 | 大厂自有团队 |
| WebView / H5 套壳 | 上线最快,热更新方便 | 播放和手势体验差,首屏慢 | 活动页、轻量工具 |
提示:选 Flutter 这条路,播放器插件是整个项目最不稳定的一环,动手改业务之前先把它跑通,别等到 UI 全改完了才发现某个机型黑屏。
2.2 目录结构逐层拆解:GetX 的模块划分长什么样
源码用的是 GetX 做状态管理和路由,目录按「基础能力 / 数据 / 业务模块」三层切分,这个分法我在几个中型项目里都验证过,边界比较干净:
lib/ ├── main.dart # 入口,初始化存储、注册全局依赖 ├── app/ │ ├── routes/ # 路由表、路由名常量、参数类型定义 │ ├── theme/ # 主题色、字体、暗黑模式配置 │ └── bindings/ # 全局 Binding,注入 HttpClient 等单例 ├── core/ │ ├── net/ # Dio 实例、拦截器、异常映射 │ ├── storage/ # Hive / SharedPreferences 封装 │ ├── utils/ # 时间格式化、屏幕适配、日志 │ └── constants/ # 接口地址、缓存 key、枚举 ├── data/ │ ├── model/ # VideoItem、VideoDetail、PlayInfo 等 │ ├── api/ # 按业务拆的接口声明 │ └── repository/ # 数据仓库,屏蔽数据来源差异 ├── modules/ │ ├── home/ # 首页:banner、推荐楼层 │ ├── category/ # 分类:筛选 + 分页列表 │ ├── search/ # 搜索:历史词、联想 │ ├── detail/ # 详情:简介、选集、相关推荐 │ ├── player/ # 播放:控制条、手势、全屏 │ └── mine/ # 我的:收藏、历史、设置 └── widgets/ # 跨模块复用组件:骨架屏、空态、视频卡片每个modules/xxx下面固定三个文件:xxx_view.dart放 UI,xxx_controller.dart放状态和业务,xxx_binding.dart负责依赖注入。这个约定值得保留,找代码时不用猜,看到页面就知道 controller 在哪。data/repository这一层是很多同类源码会省掉的,但恰恰是它决定了后面能不能低成本换数据源——业务层只认 Repository 的方法签名,不认底下是 HTTP 还是本地 mock。
2.3 环境准备与首次编译:把 flutter doctor 跑成一片绿
动手之前环境得齐。Flutter SDK 建议跟项目pubspec.yaml里声明的最低版本对齐,Android 侧装 Android Studio 和对应 SDK Platform,iOS 侧必须有 Xcode 和 CocoaPods,Windows 机器只能编 Android,这点别抱幻想。第一次拉下来先别急着flutter run,按顺序走一遍:
# 1. 检查环境,重点看 Android toolchain 和 Xcode 两项是不是打勾 flutter doctor -v # 2. 拉依赖,这一步失败九成是网络或版本约束冲突 flutter pub get # 3. iOS 还需要单独装 Pod,第一次会比较慢 cd ios && pod install --repo-update && cd .. # 4. 看设备列表,拿 deviceId flutter devices # 5. 指定设备跑起来 flutter run -d <deviceId>flutter doctor -v比不带参数有用得多,它会告诉你缺哪个 SDK Platform、Android license 有没有接受、CocoaPods 版本够不够。flutter pub get报版本冲突时,先看报错里点名的两个包,通常是某个插件锁了旧版flutter_sdk约束,去pubspec.yaml里把它放宽或者换掉,别一股脑pub upgrade,那样容易连锁升级把播放器插件搞崩。
编译通过之后第一件事不是看首页,是去lib/core/constants/里找接口地址常量,把它换成你自己的后端域名,否则首页永远是骨架屏——这个坑后面第 5 章还会细说。
3. 数据链路:从首页列表到播放页的一次完整请求
3.1 网络层封装:一个 Dio 实例管住所有请求
影视 App 的请求类型很集中:列表、详情、播放地址、用户相关。与其在每个 controller 里Dio().get(...),不如全局一个实例,把超时、鉴权头、错误码转换一次性做掉。源码里core/net/http_client.dart干的就是这件事,我按它的思路重写一版更清楚的:
// core/net/http_client.dart class HttpClient { late final Dio _dio; HttpClient({String? baseUrl, String? token}) { _dio = Dio(BaseOptions( baseUrl: baseUrl ?? ApiConfig.baseUrl, // 全局接口前缀,来自 constants connectTimeout: const Duration(seconds: 10), // 建连超时,弱网下别设太小 receiveTimeout: const Duration(seconds: 15), // 响应超时,列表接口够用 headers: { 'Accept': 'application/json', if (token != null) 'Authorization': 'Bearer $token', }, )); // 统一把后端业务码翻译成异常,业务层就不用到处判 code _dio.interceptors.add(InterceptorsWrapper( onResponse: (resp, handler) { final body = resp.data; if (body is Map && body['code'] != 0) { handler.reject(DioException( requestOptions: resp.requestOptions, error: ApiException(body['code'], body['msg']?.toString() ?? ''), )); return; } handler.next(resp); }, onError: (err, handler) { // 超时、DNS、断网统一收敛成可读文案 handler.next(err); }, )); } Future<T> get<T>(String path, { Map<String, dynamic>? query, CancelToken? cancelToken, }) async { final resp = await _dio.get(path, queryParameters: query, cancelToken: cancelToken); return resp.data['data'] as T; // 只把业务数据抛给上层 } }这段的关键在三点。baseUrl和token走构造参数注入,方便测试时换成 mock 服务器;拦截器里判断body['code'] != 0直接 reject,意味着 controller 里写try/catch就能统一处理业务错误,不用每层都写if (code == 200);get<T>返回值只取data字段,页面拿到的就是干净的模型数据。CancelToken参数别省,搜索页用户连续输入时,前一个请求该取消就取消,否则后到的旧结果会把新结果覆盖掉,这个 bug 很隐蔽。
3.2 首页与分类的分页数据流:一个 Controller 管两种列表
首页楼层和分类列表看着不一样,数据流其实同构:首次加载、下拉刷新、上拉加载更多、空态、错误态。抽一个基类出来,两个页面各继承一份,代码量能砍掉一半:
// modules/common/list_controller.dart abstract class ListController<T> extends GetxController { final items = <T>[].obs; // 响应式列表,UI 直接 Obx 监听 final loading = false.obs; // 首屏 loading final loadingMore = false.obs; // 上拉 loading,用来显示底部转圈 final hasMore = true.obs; // 后端返回空数组时置 false int _page = 1; // 子类只需实现这一个方法,告诉基类怎么取第几页 Future<List<T>> fetchPage(int page); Future<void> loadFirst() async { _page = 1; hasMore.value = true; loading.value = true; try { final list = await fetchPage(_page); items.assignAll(list); hasMore.value = list.isNotEmpty; } finally { loading.value = false; } } Future<void> loadMore() async { if (loadingMore.value || !hasMore.value) return; // 双重防护,防重复请求 loadingMore.value = true; try { final list = await fetchPage(_page + 1); if (list.isEmpty) { hasMore.value = false; // 到底了,别再触发 } else { _page += 1; items.addAll(list); } } finally { loadingMore.value = false; } } Future<void> refresh() => loadFirst(); }_page用私有字段而不是响应式的,因为页码变了不需要刷新 UI,只有items和几个状态位需要。loadMore开头那行if (loadingMore.value || !hasMore.value) return是血泪经验——列表快速上滑时,滚动监听会连着触发三四次,没有这行防护,同一个 page 会请求四次,后端返回的数据重复插入,列表里出现一模一样的卡片。hasMore在拿到空数组时立刻置 false,比靠「返回数量小于 pageSize」判断更可靠,因为有些后端最后一页刚好凑满。
3.3 路由传参与详情页组装:别把整个对象塞进 arguments
从列表点进详情,需要传的东西其实只有 id 和一个来源标记,但很多源码图省事,直接把VideoItem整个对象塞进Get.toNamed的 arguments。这在列表页看着没问题,一旦用户从推送、从历史记录、从搜索结果进来,手上根本没有完整对象,代码就得分叉。
// 列表点击:只传必要参数 Get.toNamed(Routes.detail, arguments: { 'id': item.id, 'from': 'home', // 用于埋点和返回逻辑 }); // 详情页 controller 里按需拉取 class DetailController extends GetxController { late final String videoId; final detail = Rxn<VideoDetail>(); @override void onInit() { super.onInit(); final args = Get.arguments as Map<String, dynamic>; videoId = args['id'] as String; _loadDetail(); } Future<void> _loadDetail() async { detail.value = await Get.find<VideoRepository>().fetchDetail(videoId); } }arguments用 Map 而不是强类型对象,好处是路由表里可以做一层参数校验,缺 id 直接跳回上一页,不至于整页崩掉。onInit里只做参数解析和首次拉取,不放任何 UI 相关逻辑。详情页拿到的PlayInfo(播放地址、集数列表、清晰度选项)建议在点击某一集时才请求,别在进详情页时一次性拉全集地址,那样首屏会明显变慢,而且用户大概率只看第一集。
4. 播放器与本地状态:解码、控制条和观看历史
4.1 播放器内核封装:把平台差异挡在 Controller 外面
播放器是这类项目里最容易翻车的模块。源码的做法是:Dart 层定义一个PlayerController,内部持有VideoPlayerController(或封装过的原生内核),对外只暴露play / pause / seekTo / setSpeed这几个方法和一组进度状态。业务页面完全不知道底下是 ExoPlayer 还是 AVPlayer。
// modules/player/player_controller.dart class PlayerController extends GetxController with WidgetsBindingObserver { VideoPlayerController? _inner; final isReady = false.obs; final isPlaying = false.obs; final position = Duration.zero.obs; // 当前进度,播放页进度条监听它 final duration = Duration.zero.obs; Timer? _ticker; String? _url; Future<void> prepare(String url, {Duration? startAt}) async { // 换集时必须先释放旧内核,否则 iOS 会残留音频 await _inner?.dispose(); _inner = VideoPlayerController.networkUrl( Uri.parse(url), videoPlayerOptions: VideoPlayerOptions(mixWithOthers: false), ); await _inner!.initialize(); duration.value = _inner!.value.duration; if (startAt != null && startAt < duration.value) { await _inner!.seekTo(startAt); // 从上次看到的位置续播 } isReady.value = true; _bindListener(); _startTicker(); } void _bindListener() { _inner!.addListener(() { final v = _inner!.value; isPlaying.value = v.isPlaying; if (v.hasError) { // 解码失败统一走这里,交给上层弹重试 isReady.value = false; } }); } // 高频进度更新交给定时器,减轻 value 监听的压力 void _startTicker() { _ticker?.cancel(); _ticker = Timer.periodic(const Duration(milliseconds: 500), (_) async { if (_inner != null && _inner!.value.isInitialized) { position.value = await _inner!.position ?? Duration.zero; } }); } @override void onClose() { _ticker?.cancel(); _inner?.dispose(); // 少了这行,退出播放页后音频还在响 super.onClose(); } }prepare里先dispose再新建,是为了处理「切换集数」这个高频动作——直接复用旧 controller 再换 URL,在 iOS 上经常出现旧音轨没停、新画面不出的情况。startAt参数接的是历史进度,注意要做一次startAt < duration的判断,否则后端返回的时长和本地记录对不上时,seek 会直接失败。_ticker用 500 毫秒轮询而不是监听value,是因为VideoPlayerController.value变化非常频繁,直接驱动 UI 会让进度条组件重绘上百次,卡顿就是这么来的。
4.2 播放控制、全屏切换与生命周期
播放页的手势和全屏是体验分水岭。常见做法是竖屏进入、点击全屏按钮时用SystemChrome.setPreferredOrientations切横屏并隐藏状态栏,退出时再还原。这里要盯的是生命周期:用户切到后台、接了个电话、下拉通知栏,播放器必须暂停,否则声音会在后台继续放,用户第一反应就是卸载。
@override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.paused) { _inner?.pause(); _saveHistory(); // 切后台顺手落一次进度 } }WidgetsBindingObserver记得在onInit里WidgetsBinding.instance.addObserver(this),onClose里移除。忘记移除监听,页面销毁后回调还在跑,会拿到一个已释放的 controller,日志里一堆was used after being disposed。
全屏切换还有一个细节:横屏时状态栏隐藏后,页面顶部会多出一条安全区空白。别用固定 padding 硬怼,用MediaQuery.of(context).padding动态取,刘海屏和挖孔屏的数值差别不小。
4.3 收藏、历史与本地缓存:Hive 比想象中好用
收藏和历史这类数据,用户不在乎丢不丢,但很在乎点开是不是秒出。走本地存储最稳,Hive 在这个场景比 SharedPreferences 合适,因为它能直接存对象列表,不用每次手动jsonEncode。
// core/storage/history_store.dart class HistoryStore { static const _boxName = 'watch_history'; static const _maxCount = 200; // 只留最近 200 条,防止盒子无限膨胀 late final Box<Map> _box; Future<void> init() async { _box = await Hive.openBox<Map>(_boxName); } Future<void> save({ required String videoId, required String title, required String cover, required int episode, required Duration position, }) async { await _box.put(videoId, { 'title': title, 'cover': cover, 'episode': episode, 'positionMs': position.inMilliseconds, 'updatedAt': DateTime.now().millisecondsSinceEpoch, }); if (_box.length > _maxCount) { // 按更新时间排序,删掉最旧的那批 final entries = _box.toMap().entries.toList() ..sort((a, b) => (a.value['updatedAt'] as int) .compareTo(b.value['updatedAt'] as int)); for (final e in entries.take(_box.length - _maxCount)) { await _box.delete(e.key); } } } List<Map> listSorted() { final list = _box.values.toList(); list.sort((a, b) => (b['updatedAt'] as int).compareTo(a['updatedAt'] as int)); return list; } }用videoId当 key 是关键,同一部剧重复观看只会更新那一条记录,不会在历史里刷出几十条同名条目。_maxCount的裁剪逻辑放在写入之后,比每次读取时判断更省事。positionMs存毫秒而不是秒,避免精度丢失导致续播位置偏移。这套结构改成收藏夹也几乎不用动,换个 box 名、去掉 position 字段就行。
5. 避坑与排查:从白屏黑屏到编译失败的六条记录
5.1 构建期:环境对了,依赖不一定对
现象一:pod install一直卡在Analyzing dependencies,最后超时失败。原因通常是 CocoaPods 的 spec 仓库太旧或者本地索引损坏,不是网络问题。解决是先删ios/Pods和Podfile.lock,执行pod repo update再重新pod install。如果还是慢,检查Podfile里有没有锁死某个老版本插件,影视类项目常带的播放器插件对 iOS 最低版本有要求,platform :ios, '12.0'这种写法该往上提就提。
现象二:Android 编译报Duplicate class或者minSdkVersion冲突。原因多半是两个插件各自带了一份不同版本的同一个原生库,或者某个插件要求 minSdk 21 而项目还是 19。解决是在android/app/build.gradle里统一minSdkVersion,再用./gradlew app:dependencies找出重复的那条依赖,用exclude group:排掉旧的那份。别直接在gradle.properties里开android.enableJetifier=true糊过去,那只是把冲突推迟到运行时。
5.2 运行期:接口通了,页面还是白的
现象三:App 能启动,首页一直是骨架屏,日志里没有任何报错。这检查三个地方:lib/core/constants/里的接口域名是不是还指向源码自带的演示地址;Android 9 以上默认禁止明文 HTTP,如果后端是 http,必须在AndroidManifest.xml里配android:usesCleartextTraffic="true"或者加network_security_config;iOS 同理,Info.plist里没有 ATS 例外,https 证书链不完整一样静默失败。这类问题最坑的地方就是它不报错,只表现为没数据。
现象四:列表滑到一半突然全部重建成第一页。原因通常是 Controller 没在 Binding 里注册成单例,每次页面重建都新建一个,items自然回到初始状态。检查xxx_binding.dart里用的是Get.put还是Get.lazyPut,前者立即实例化、后者懒加载,但两者默认都是单例;如果写成了Get.create,那就是每次取都新建,改掉即可。
现象五:快速上下滑动列表,图片错位,A 卡片的封面显示在 B 卡片上。这是图片组件复用时没做 key 绑定。ListView.builder回收 item 时,如果图片组件只按 URL 加载却不校验当前 item 身份,就会出现串图。解决是给图片组件加key: ValueKey(item.id),同时用cached_network_image加上memCacheWidth限制解码尺寸,顺手把内存占用也压下去——不加这个参数,一张 1080P 封面按原尺寸解码进内存,滑十几屏就能把低端机撑爆。
5.3 播放与发布:最容易在上线前一刻爆炸
现象六:某个机型上播放页黑屏但有声音。这是硬解码兼容问题,不是代码写错。原因是该机型对某个编码 profile 的硬解支持不完整,ExoPlayer 直接抛错但不一定回调到 Dart 层。解决是在播放器初始化时配置降级策略,Android 侧通过DefaultRenderersFactory设置setEnableDecoderFallback(true),让它自动回退到软解;同时在addListener里监听value.hasError,一旦出错就给用户一个「切换解码方式重试」的按钮,别让人对着黑屏干等。
现象七:iOS 上传 App Store 被拒,理由指向权限描述缺失或后台音频。影视 App 常带后台播放,UIBackgroundModes里加了audio却没在代码里真正处理音频会话,审核会判定滥用。要么老老实实实现后台播放的音频会话管理,要么把这个声明删掉。权限描述文案也一样,NSCameraUsageDescription这类字段为空或者写「需要此权限」都会被拒。
6. 二次开发:换皮、换源、加功能的三条改造路径
拿到源码后最常见的三个改造诉求是:换成自己的品牌 UI、接自己的后端、加投屏或者弹幕。这三件事的难度和风险差得很远,先看一张对照表再动手:
| 改造目标 | 主要改动位置 | 风险点 | 工作量 |
|---|---|---|---|
| 换主题、换图标、换启动页 | app/theme、assets、原生工程资源目录 | 图标尺寸不全导致 iOS 审核警告 | 低 |
| 接自有后端接口 | core/constants、data/api、data/repository | 字段结构不一致,模型解析全线报错 | 中 |
| 加投屏 / 弹幕 / 倍速记忆 | modules/player、新增原生通道 | 播放器状态与外部通道时序错乱 | 高 |
换皮最省事,但别只改ThemeData就完事。启动页、应用图标、状态栏颜色这三处是原生资源,Flutter 层改不到,得去android/app/src/main/res和ios/Runner/Assets.xcassets里替换,尺寸规格对不上会被打包工具直接拒掉。
接自有后端是改造的大头,最稳的做法是先在data/repository层加一个适配器,而不是去改api里已有的方法签名:
// data/repository/video_source.dart // 定义一个数据源契约,旧的 HTTP 实现和新的后端实现都实现它 abstract class VideoSource { Future<List<VideoItem>> fetchList({ required int page, required String categoryId, int pageSize = 20, }); Future<VideoDetail> fetchDetail(String videoId); // episode 从 1 开始,清晰度用枚举而不是字符串,避免后端改文案就崩 Future<PlayInfo> fetchPlayInfo(String videoId, int episode, {Quality quality = Quality.auto}); } // 新后端的实现,字段映射全部收在这里 class RemoteVideoSource implements VideoSource { final HttpClient _http; RemoteVideoSource(this._http); @override Future<List<VideoItem>> fetchList({ required int page, required String categoryId, int pageSize = 20, }) async { final raw = await _http.get<List>('/v1/videos', query: { 'page': page, 'size': pageSize, 'cat': categoryId, }); // 后端字段名和本地模型不一致时,只在这里做一次转换 return raw.map((e) => VideoItem.fromJson(e as Map<String, dynamic>)).toList(); } @override Future<VideoDetail> fetchDetail(String videoId) async { final raw = await _http.get<Map<String, dynamic>>('/v1/videos/$videoId'); return VideoDetail.fromJson(raw); } @override Future<PlayInfo> fetchPlayInfo(String videoId, int episode, {Quality quality = Quality.auto}) async { final raw = await _http.get<Map<String, dynamic>>( '/v1/videos/$videoId/play', query: {'ep': episode, 'q': quality.name}, ); return PlayInfo.fromJson(raw); } }fetchList里的pageSize给了默认值 20,是为了让调用方不用每次都传,同时保留可调空间——后端如果限制单页最大 50,超了会被截断,那就得在 Repository 里做二次分页补齐,这个逻辑藏在适配器里比散在页面里强。Quality用枚举而不是直接传字符串,好处是后端把「高清」改成「蓝光」的时候,你只需要改fromJson里的一处映射,UI 上的清晰度选择器完全不受影响。接入新源时先在main.dart的全局 Binding 里把RemoteVideoSource注册进去,旧实现留着当兜底,灰度期间可以按配置切换。
加投屏这类功能,坑主要在时序:投屏 SDK 初始化是异步的,而播放器可能已经处于播放状态,两边状态不同步就会出现在电视上播了、手机上还在放的情况。我的做法是抽一个PlaybackCoordinator统一管播放状态,本地播放器和投屏通道都订阅它,谁都不直接改状态,只发意图。这样即使以后再加一个「车载模式」,也不用动播放器本身。
说个我自己的教训。早期拆这类项目,我总喜欢先把 UI 全改一遍,觉得视觉上像自己的东西了才算接手,结果每次都是改到播放页卡住,回头发现接口字段对不上,前面改的页面全要返工。后来固定成一条流程:先跑通、再看日志确认接口通、然后把repository层的适配器写完、最后才动 UI。顺序颠倒一次,返工的时间够我把适配器写三遍。希望帮到你。
本文还有配套的精品资源,点击获取