原创研发flutter3+getX+mediaKit仿抖音app 短视频直播实战Flutter-DouYin。
flutter3_dylive 使用最新跨平台技术flutter3.x+dart3+getx+get_storage+media_kit开发手机端仿抖音app小视频直播实战项目。实现了抖音全屏式上下滑动视频、左右滑动切换页面模块,直播间进场/礼物动画,聊天等模块。

技术栈
- 编辑器:Vscode
- 技术框架:Flutter3.19.2+Dart3.3.0
- 路由/状态管理:get: ^4.6.6
- 本地缓存:get_storage: ^2.1.1
- 图片预览插件:photo_view: ^0.14.0
- 刷新加载:easy_refresh^3.3.4
- toast轻提示:toast^0.3.0
- 视频套件:media_kit: ^1.1.10+1


flutter_douyin实现了类似抖音全屏沉浸式滑动效果(上下滑动视频、左右切换页面模块)。
如下图:实现了左右滑动的同时,顶部状态栏+Tab菜单+底部bottomNavigationBar导航栏三者联动效果。

项目结构目录

在项目创建前期,需要配置好Flutter SDK和Dart SDK开发环境。
如果是使用Vscode编辑器开发flutter项目,大家需自行配置flutter/dart扩展语法插件。

















项目中的一些技术知识点,有些是在之前的项目flutter3聊天实例中有介绍过,感兴趣的话可以去看看。
https://www.cnblogs.com/xiaoyan2017/p/18008370
https://www.cnblogs.com/xiaoyan2017/p/18048244
Flutter入口配置main.dart
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:media_kit/media_kit.dart';
import 'utils/index.dart';
// 引入布局模板
import 'layouts/index.dart';
import 'binding/binding.dart';
// 引入路由管理
import 'router/index.dart';
void main() async {
// 初始化get_storage
await GetStorage.init();
// 初始化media_kit
WidgetsFlutterBinding.ensureInitialized();
MediaKit.ensureInitialized();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return GetMaterialApp(
title: 'FLUTTER3 DYLIVE',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFFFE2C55)),
useMaterial3: true,
// 修正windows端字体粗细不一致
fontFamily: Platform.isWindows ? 'Microsoft YaHei' : null,
),
home: const Layout(),
// 全局绑定GetXController
initialBinding: GlobalBindingController(),
// 初始路由
initialRoute: Utils.isLogin() ? '/' : '/login',
// 路由页面
getPages: routePages,
// 错误路由
// unknownRoute: GetPage(name: '/404', page: Error),
);
}
}
Flutter3底部导航栏


底部导航栏使用 bottomNavigationBar 组件实现页面模块切换。通过getx全局状态来联动控制底部导航栏背景颜色。
导航栏中间图标/图片按钮,使用了 Positioned 组件定位实现功能。
return Scaffold(
backgroundColor: Colors.grey[50],
body: pageList[pageCurrent],
// 底部导航栏
bottomNavigationBar: Theme(
// Flutter去掉BottomNavigationBar底部导航栏的水波纹
data: ThemeData(
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
hoverColor: Colors.transparent,
),
child: Obx(() {
return Stack(
children: [
Container(
decoration: const BoxDecoration(
border: Border(top: BorderSide(color: Colors.black54, width: .1)),
),
child: BottomNavigationBar(
backgroundColor: bottomNavigationBgcolor(),
fixedColor: FStyle.primaryColor,
unselectedItemColor: bottomNavigationItemcolor(),
type: BottomNavigationBarType.fixed,
elevation: 1.0,
unselectedFontSize: 12.0,
selectedFontSize: 12.0,
currentIndex: pageCurrent,
items: [
...pageItems
],
onTap: (index) {
setState(() {
pageCurrent = index;
});
},
),
),
// 自定义底部导航栏中间按钮
Positioned(
left: MediaQuery.of(context).size.width / 2 - 15,
top: 0,
bottom: 0,
child: InkWell(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Icon(Icons.tiktok, color: bottomNavigationItemcolor(centerDocked: true), size: 32.0,),
Image.asset('assets/images/applogo.png', width: 32.0, fit: BoxFit.contain,)
// Text('直播', style: TextStyle(color: bottomNavigationItemcolor(centerDocked: true), fontSize: 12.0),)
],
),
onTap: () {
setState(() {
pageCurrent = 2;
});
},
),
),
],
);
}),
),
);
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../styles/index.dart';
import '../../controllers/page_video_controller.dart';
// 引入pages页面
import '../pages/index/index.dart';
import '../pages/video/index.dart';
import '../pages/live/index.dart';
import '../pages/message/index.dart';
import '../pages/my/index.dart';
class Layout extends StatefulWidget {
const Layout({super.key});
@override
State<Layout> createState() => _LayoutState();
}
class _LayoutState extends State<Layout> {
PageVideoController pageVideoController = Get.put(PageVideoController());
// page索引
int pageCurrent = 0;
// page页面
List pageList = [const Index(), const FVideo(), const FLiveList(), const Message(), const My()];
// tabs选项
List pageItems = [
const BottomNavigationBarItem(
icon: Icon(Icons.home_outlined),
label: '首页'
),
const BottomNavigationBarItem(
icon: Icon(Icons.play_arrow_outlined),
label: '短视频'
),
const BottomNavigationBarItem(
icon: Icon(Icons.live_tv_rounded, color: Colors.transparent,),
label: ''
),
BottomNavigationBarItem(
icon: Stack(
alignment: const Alignment(4, -2),
children: [
const Icon(Icons.messenger_outline),
FStyle.badge(1)
],
),
label: '消息'
),
BottomNavigationBarItem(
icon: Stack(
alignment: const Alignment(1.5, -1),
children: [
const Icon(Icons.person_outline),
FStyle.badge(0, isdot: true)
],
),
label: '我'
)
];
// 底部导航栏背景色
Color bottomNavigationBgcolor() {
int index = pageCurrent;
int pageVideoTabIndex = pageVideoController.pageVideoTabIndex.value;
Color color = Colors.white;
if(index == 1) {
if([1, 2, 3].contains(pageVideoTabIndex)) {
color = Colors.white;
}else {
color = Colors.black;
}
}
return color;
}
// 底部导航栏颜色
Color bottomNavigationItemcolor({centerDocked = false}) {
int index = pageCurrent;
int pageVideoTabIndex = pageVideoController.pageVideoTabIndex.value;
Color color = Colors.black54;
if(index == 1) {
if([1, 2, 3].contains(pageVideoTabIndex)) {
color = Colors.black54;
}else {
color = Colors.white60;
}
}else if(index == 2 && centerDocked) {
color = FStyle.primaryColor;
}
return color;
}
// ...
}
Flutter实现抖音全屏式滑动
如下图:实现类似抖音左右滑动全屏切换页面模块、上下滑动丝滑切换短视频。




采用 TabBar 和 PageView 联动页面滑动。
return Scaffold(
extendBodyBehindAppBar: true,
appBar: AppBar(
forceMaterialTransparency: true,
backgroundColor: [1, 2, 3].contains(pageVideoController.pageVideoTabIndex.value) ? null : Colors.transparent,
foregroundColor: [1, 2, 3].contains(pageVideoController.pageVideoTabIndex.value) ? Colors.black : Colors.white,
titleSpacing: 1.0,
leading: Obx(() => IconButton(icon: Icon(Icons.menu, color: tabColor(),), onPressed: () {},),),
title: Obx(() {
return TabBar(
controller: tabController,
tabs: pageTabs.map((v) => Tab(text: v)).toList(),
isScrollable: true,
tabAlignment: TabAlignment.center,
overlayColor: MaterialStateProperty.all(Colors.transparent),
unselectedLabelColor: unselectedTabColor(),
labelColor: tabColor(),
indicatorColor: tabColor(),
indicatorSize: TabBarIndicatorSize.label,
unselectedLabelStyle: const TextStyle(fontSize: 16.0, fontFamily: 'Microsoft YaHei'),
labelStyle: const TextStyle(fontSize: 16.0, fontFamily: 'Microsoft YaHei', fontWeight: FontWeight.w600),
dividerHeight: 0,
labelPadding: const EdgeInsets.symmetric(horizontal: 10.0),
indicatorPadding: const EdgeInsets.symmetric(horizontal: 5.0),
onTap: (index) {
pageVideoController.updatePageVideoTabIndex(index); // 更新索引
pageController.jumpToPage(index);
},
);
}),
actions: [
Obx(() => IconButton(icon: Icon(Icons.search, color: tabColor(),), onPressed: () {},),),
],
),
body: Column(
children: [
Expanded(
child: Stack(
children: [
/// 水平滚动模块
PageView(
// 自定义滚动行为(支持桌面端滑动、去掉滚动条槽)
scrollBehavior: PageScrollBehavior().copyWith(scrollbars: false),
scrollDirection: Axis.horizontal,
controller: pageController,
onPageChanged: (index) {
pageVideoController.updatePageVideoTabIndex(index); // 更新索引
setState(() {
tabController.animateTo(index);
});
},
children: [
...pageModules
],
),
],
),
),
],
),
);
PageVideoController pageVideoController = Get.put(PageVideoController());
List<String> pageTabs = ['热点', '长视频', '文旅', '商城', '关注', '同城服务', '推荐'];
final pageModules = [
const HotModule(),
const LongVideoModule(),
const TripModule(),
const MallModule(),
const FavorModule(),
const NearModule(),
const RecommendModule()
];
late final TabController tabController = TabController(initialIndex: pageVideoController.pageVideoTabIndex.value, length: pageTabs.length, vsync: this);
// 页面controller
late final PageController pageController = PageController(initialPage: pageVideoController.pageVideoTabIndex.value, viewportFraction: 1.0);
@override
void dispose() {
tabController.dispose();
pageController.dispose();
super.dispose();
}
flutter短视频mini进度条

底部mini播放进度条一开始是采用自定义组件实现效果,不过不能拖动滚动条进度,后来就采用了Slider组件来实现。
// flutter滑动短视频模块 Q:282310962
return Container(
color: Colors.black,
child: Column(
children: [
Expanded(
child: Stack(
children: [
/// 垂直滚动模块
PageView.builder(
// 自定义滚动行为(支持桌面端滑动、去掉滚动条槽)
scrollBehavior: PageScrollBehavior().copyWith(scrollbars: false),
scrollDirection: Axis.vertical,
controller: pageController,
onPageChanged: (index) async {
...
},
itemCount: videoList.length,
itemBuilder: (context, index) {
return Stack(
children: [
// 视频区域
Positioned(
top: 0,
left: 0,
right: 0,
bottom: 0,
child: GestureDetector(
child: Stack(
children: [
// 短视频插件
Visibility(
visible: videoIndex == index,
child: Video(
controller: videoController,
fit: BoxFit.cover,
// 无控制条
controls: NoVideoControls,
),
),
// 播放/暂停按钮
StreamBuilder(
stream: player.stream.playing,
builder: (context, playing) {
return Visibility(
visible: playing.data == false,
child: Center(
child: IconButton(
padding: EdgeInsets.zero,
onPressed: () {
player.playOrPause();
},
icon: Icon(
playing.data == true ? Icons.pause : Icons.play_arrow_rounded,
color: Colors.white70,
size: 70,
),
),
),
);
},
),
],
),
onTap: () {
player.playOrPause();
},
),
),
// 右侧操作栏
Positioned(
bottom: 15.0,
right: 10.0,
child: Column(
...
),
),
// 底部信息区域
Positioned(
bottom: 15.0,
left: 10.0,
right: 80.0,
child: Column(
...
),
),
// 播放mini进度条
Positioned(
bottom: 0.0,
left: 10.0,
right: 10.0,
child: Visibility(
visible: videoIndex == index && position > Duration.zero,
child: Listener(
child: SliderTheme(
data: const SliderThemeData(
trackHeight: 2.0,
thumbShape: RoundSliderThumbShape(enabledThumbRadius: 4.0), // 调整滑块的大小
// trackShape: RectangularSliderTrackShape(), // 使用矩形轨道形状
overlayShape: RoundSliderOverlayShape(overlayRadius: 0), // 去掉Slider默认上下边距间隙
inactiveTrackColor: Colors.white24, // 设置非活动进度条的颜色
activeTrackColor: Colors.white, // 设置活动进度条的颜色
thumbColor: Colors.pinkAccent, // 设置滑块的颜色
overlayColor: Colors.transparent, // 设置滑块覆盖层的颜色
),
child: Slider(
value: sliderValue,
onChanged: (value) async {
// debugPrint('当前视频播放时间$value');
setState(() {
sliderValue = value;
});
// 跳转播放时间
await player.seek(duration * value.clamp(0.0, 1.0));
},
onChangeEnd: (value) async {
setState(() {
sliderDraging = false;
});
// 继续播放
if(!player.state.playing) {
await player.play();
}
},
),
),
onPointerMove: (e) {
setState(() {
sliderDraging = true;
});
},
),
),
),
// 视频播放时间
Positioned(
bottom: 90.0,
left: 10.0,
right: 10.0,
child: Visibility(
visible: sliderDraging,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(position.label(reference: duration), style: const TextStyle(color: Colors.white, fontSize: 16.0, fontWeight: FontWeight.w600),),
Container(
margin: const EdgeInsets.symmetric(horizontal: 7.0),
child: const Text('/', style: TextStyle(color: Colors.white54, fontSize: 10.0,),),
),
Text(duration.label(reference: duration), style: const TextStyle(color: Colors.white54, fontSize: 16.0, fontWeight: FontWeight.w600),),
],
),
),
),
],
);
},
),
/// 固定层
// 红包
Positioned(
left: 15.0,
top: MediaQuery.of(context).padding.top + 20,
child: Container(
height: 40.0,
width: 40.0,
decoration: BoxDecoration(
color: Colors.black12,
borderRadius: BorderRadius.circular(100.0),
),
child: UnconstrainedBox(
child: Image.asset('assets/images/time-hb.png', width: 30.0, fit: BoxFit.contain,),
),
),
),
],
),
),
],
),
);

flutter3直播模块
flutter直播模块包含了顶部信息、底部进场提示+商品层+弹幕层+操作栏等几个模块。


// 商品购买动效
Container(
...
),
// 加入直播间动效
const AnimationLiveJoin(
joinQueryList: [
{'avatar': 'assets/images/logo.png', 'name': 'andy'},
{'avatar': 'assets/images/logo.png', 'name': 'jack'},
{'avatar': 'assets/images/logo.png', 'name': '一条咸鱼'},
{'avatar': 'assets/images/logo.png', 'name': '四季平安'},
{'avatar': 'assets/images/logo.png', 'name': '叶子'},
],
),
// 送礼物动效
const AnimationLiveGift(
giftQueryList: [
{'label': '小心心', 'gift': 'assets/images/gift/gift1.png', 'user': 'Jack', 'avatar': 'assets/images/avatar/uimg2.jpg', 'num': 12},
{'label': '棒棒糖', 'gift': 'assets/images/gift/gift2.png', 'user': 'Andy', 'avatar': 'assets/images/avatar/uimg6.jpg', 'num': 36},
{'label': '大啤酒', 'gift': 'assets/images/gift/gift3.png', 'user': '一条咸鱼', 'avatar': 'assets/images/avatar/uimg1.jpg', 'num': 162},
{'label': '人气票', 'gift': 'assets/images/gift/gift4.png', 'user': 'Flower', 'avatar': 'assets/images/avatar/uimg5.jpg', 'num': 57},
{'label': '鲜花', 'gift': 'assets/images/gift/gift5.png', 'user': '四季平安', 'avatar': 'assets/images/avatar/uimg3.jpg', 'num': 6},
{'label': '捏捏小脸', 'gift': 'assets/images/gift/gift6.png', 'user': 'Alice', 'avatar': 'assets/images/avatar/uimg4.jpg', 'num': 28},
{'label': '你真好看', 'gift': 'assets/images/gift/gift7.png', 'user': '叶子', 'avatar': 'assets/images/avatar/uimg7.jpg', 'num': 95},
{'label': '亲吻', 'gift': 'assets/images/gift/gift8.png', 'user': 'YOYO', 'avatar': 'assets/images/avatar/uimg8.jpg', 'num': 11},
{'label': '玫瑰', 'gift': 'assets/images/gift/gift12.png', 'user': '宇辉', 'avatar': 'assets/images/avatar/uimg9.jpg', 'num': 3},
{'label': '私人飞机', 'gift': 'assets/images/gift/gift16.png', 'user': 'Hison', 'avatar': 'assets/images/avatar/uimg10.jpg', 'num': 273},
],
),
// 直播弹幕+商品讲解
Container(
margin: const EdgeInsets.only(top: 7.0),
height: 200.0,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: liveJson[index]['message']?.length,
itemBuilder: (context, i) => danmuList(liveJson[index]['message'])[i],
),
),
SizedBox(
width: isVisibleGoodsTalk ? 7 : 35,
),
// 商品讲解
Visibility(
visible: isVisibleGoodsTalk,
child: Column(
...
),
),
],
),
),
// 底部工具栏
Container(
margin: const EdgeInsets.only(top: 7.0),
child: Row(
...
),
),
直播聊天弹幕通过Flexible配合Text.rich / TextSpan实现消息自适应布局界面。
通过 SlideTransition 组件实现直播进场动画。
return SlideTransition(
position: animationFirst ? animation : animationMix,
child: Container(
alignment: Alignment.centerLeft,
margin: const EdgeInsets.only(top: 7.0),
padding: const EdgeInsets.symmetric(horizontal: 7.0,),
height: 23.0,
width: 250,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
Color(0xFF6301FF), Colors.transparent
],
),
borderRadius: BorderRadius.horizontal(left: Radius.circular(10.0)),
),
child: joinList!.isNotEmpty ?
Text('欢迎 ${joinList![0]['name']} 加入直播间', style: const TextStyle(color: Colors.white, fontSize: 14.0,),)
:
Container()
,
),
);
class _AnimationLiveJoinState extends State<AnimationLiveJoin> with TickerProviderStateMixin {
// 动画控制器
late AnimationController controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 500), // 第一个动画持续时间
);
late AnimationController controllerMix = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1000), // 第二个动画持续时间
);
// 动画
late Animation<Offset> animation = Tween(begin: const Offset(2.5, 0), end: const Offset(0, 0)).animate(controller);
late Animation<Offset> animationMix = Tween(begin: const Offset(0, 0), end: const Offset(-2.5, 0)).animate(controllerMix);
Timer? timer;
// 是否第一个动画
bool animationFirst = true;
// 是否空闲
bool idle = true;
// 加入直播间数据列表
List? joinList;
@override
void initState() {
super.initState();
joinList = widget.joinQueryList!.toList();
runAnimation();
animation.addListener(() {
if(animation.status == AnimationStatus.forward) {
debugPrint('第一个动画进行中');
idle = false;
setState(() {});
}else if(animation.status == AnimationStatus.completed) {
debugPrint('第一个动画结束');
animationFirst = false;
if(controllerMix.isCompleted || controllerMix.isDismissed) {
timer = Timer(const Duration(seconds: 2), () {
controllerMix.forward();
debugPrint('第二个动画开始');
});
}
setState(() {});
}
});
animationMix.addListener(() {
if(animationMix.status == AnimationStatus.forward) {
setState(() {});
}else if(animationMix.status == AnimationStatus.completed) {
animationFirst = true;
controller.reset();
controllerMix.reset();
if(joinList!.isNotEmpty) {
joinList!.removeAt(0);
}
idle = true;
// 执行下一个数据
runAnimation();
setState(() {});
}
});
}
void runAnimation() {
if(joinList!.isNotEmpty) {
// 空闲状态才能执行,防止添加数据播放状态混淆
if(idle == true) {
if(controller.isCompleted || controller.isDismissed) {
setState(() {});
timer = Timer(Duration.zero, () {
controller.forward();
});
}
}
}
}
@override
void dispose() {
controller.dispose();
controllerMix.dispose();
timer?.cancel();
super.dispose();
}
}
综上就是flutter3实战开发仿抖音的一些知识分享,由于涉及的技术点还是蛮多的,就没有非常详细的展开介绍了。希望以上的一些分享能给大家有些帮助~
最后附上两个跨端项目
uniapp+vue3短视频直播商城:https://www.cnblogs.com/xiaoyan2017/p/17938517
tauri+vite4中后台管理系统:https://www.cnblogs.com/xiaoyan2017/p/17552562
