ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Flutter仿微信红包实战:自定义绘制、动画与随机算法全解析

2026/9/2 4:37:50 拓冰建站 浏览量
Flutter仿微信红包实战:自定义绘制、动画与随机算法全解析 简介适合前端初学者练习的仿微信红包交互实例基于页面结构、样式与脚本实现覆盖红包发放、金额随机分配、领取展示与转发诱导等核心环节可作为理解前端动画、表单处理与文档对象模型操作的练手项目。压缩包共十七个文件以图片、脚本、样式和页面文件为主图片包含常见的切片与动态图脚本负责交互逻辑样式文件统一定义视觉风格首页入口便于直接运行整体约二百三十千字节轻量易部署。目前已有九百三十人学习适合希望系统掌握前端综合开发流程的读者。通过梳理页面源码可以学到脚本库的引入与调用、关键帧动画实现、红包金额随机算法及事件监听等实用技巧资源内的多张切图和动态图也提供了完整素材便于直接修改复用。整体结构清晰既是仿微信红包交互的完整样例也是练习移动端适配与性能优化的参考。 仿微信红包 1这个项目标题乍一看像是随手一写但真要动起手来里面装的是移动端最经典的一套完整交互链路红包列表、点击弹窗、拆包动画、金额揭晓、记录落盘。我第一反应是做这个项目的人想练的绝不只是画一个红包而是把自定义绘制、动画状态管理、随机算法、本地持久化一次性串起来跑通。这篇文章我就以 Flutter 为例从零到这个系列的第一版闭环完整拆一遍。适合已经写过几个 Demo、想通过复刻经典界面来提升自定义绘制和动画能力的开发者或者正准备面试、想拿一个有点分量的实战项目的朋友。1. 先搞清楚这个仿字到底仿的是什么1.1 从标题拆解需求边界很多人看到仿微信红包就一头扎进代码里结果画了两天UI又删掉重来。问题不在手速在于没先想清楚仿到什么程度。我的建议是第一版先砍掉一切不属于核心链路的东西。这个系列的第一版我圈定的功能边界只有四个一个红包列表页能展示多个不同状态的红包。点击红包后弹出红包弹窗模拟被塞到你面前的感觉。点击开按钮后播放一段拆包动画然后揭晓金额。拆过的红包要有记录重启 App 后还在。至于群聊、拼手气、过期退回、并发抢红包全部留到后面版本。第一版的核心目标是把列表 - 弹窗 - 动画 - 结果 - 存储这条链路打磨顺。边界一清楚代码量瞬间就可控了。1.2 技术选型为什么选 Flutter技术栈选择上可选的方案很多Android 原生、小程序、H5、React Native、Flutter。我这次选 Flutter原因有几个。第一自定义绘制能力实在太顺手了。红包的锯齿边缘、金色渐变、圆形按钮用 CustomPainter 写出来非常直观。第二Flutter 的动画体系成熟AnimationController 加各种 Curves能做出很接近真实手感的弹簧回弹效果。第三跨平台写一套逻辑iOS 和 Android 都能跑后面想套壳成小程序也方便迁移思路。还有一个很现实的原因Flutter 对状态管理的方案足够清晰ChangeNotifier、Provider、Bloc 都有成熟的社区实践很适合这个项目拆包状态全局共享的场景。如果你本身是做原生开发也不用觉得这文章白看核心的思路——状态机设计、随机金额算法、动画节奏控制——是跨技术栈通用的换成 Android XML 或者 SwiftUI 一样能落地。2. 红包封面 UI 到底怎么画出来的2.1 把红包拆成图层金字塔动手画 UI 之前先别急着写代码。把微信红包的视觉拆开看它并不是一个单独的矩形而是一组图层叠出来的结果最底层是红包的身体一个带圆角的矩形用金色渐变填充。身体顶部是红包盖颜色比底部稍微浅一点上下之间有一条分界线。底部边缘有一圈锯齿这是最标志性的视觉元素。中间是开字圆钮通常是金色背景加红色文字。最上方覆盖一层文字信息比如恭喜发财。我用一个自定义 Column 来搭这个金字塔。先把背景渐变画好再叠加锯齿然后放文字和按钮。图层顺序一点都不能乱否则锯齿会被按钮挡住。2.2 锯齿边缘用 CustomPainter 画不用图片很多新手会去网上找一张锯齿图贴上去看起来很省事但一遇到不同屏幕宽度就露馅图片拉伸变形锯齿宽窄不一。正确做法是直接用代码算出来。class SawtoothPainter extends CustomPainter { SawtoothPainter({required this.color, required this.count, required this.depth}); final Color color; final int count; final double depth; override void paint(Canvas canvas, Size size) { final paint Paint() ..color color ..isAntiAlias true; final path Path(); final step size.width / count; // 从左上角开始依次画向下的锯齿 path.moveTo(0, depth); for (int i 1; i count; i) { final x i * step; if (i.isOdd) { path.lineTo(x, depth); } else { path.lineTo(x - step / 2, depth * 2); } } path.lineTo(size.width, size.height); path.lineTo(0, size.height); path.close(); canvas.drawPath(path, paint); } override bool shouldRepaint(covariant SawtoothPainter oldDelegate) { return oldDelegate.color ! color || oldDelegate.count ! count || oldDelegate.depth ! depth; } }这里我做了一个很关键的决定锯齿不是贴在整个红包的底部而是贴在一个单独的金色容器下方这样红包盖和底部之间可以自然留出分隔。实际跑下来这种在底部绘制锯齿的写法在不同屏幕宽度下都能保持宽度一致因为锯齿数量固定、单个锯齿宽度由size.width / count动态算出来不会出现拉伸变形。注意锯齿的count不要设得太密否则在高分辨率屏幕上会显得很碎。我实测 24 到 32 之间观感最合适。2.3 渐变、圆角与布局细节红包主体我用Container加BoxDecoration实现金色渐变从左上到右下。这个渐变的方向很影响质感方向对了金色才会显得有光泽感而不是一块死板的黄。BoxDecoration( gradient: const LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFFF7D488), Color(0xFFE89A3C), Color(0xFFD9772A)], ), borderRadius: BorderRadius.circular(16), )中间那个开字按钮我用Material包一个圆形外层再加一圈细描边。关键点在于点击区域要明显大于视觉区域避免用户明明按到了、开字却没有任何反应。整个弹窗我固定在页面中央宽高比保持在 0.78 左右接近真实红包的视觉比例。你可以直接跑真机看效果如果觉得扁了就调整这个比例。3. 拆红包动画与状态流转这是灵魂3.1 先设计状态机再写动画动画最怕写到一半发现状态乱了。我一开始就直接踩了坑拆完一个红包、关掉弹窗、再点同一个红包它又能拆。问题出在我没做状态隔离。后来我老老实实给红包定义了三个状态enum RedPacketStatus { idle, // 未拆 opening, // 拆包动画进行中 opened, // 已拆开 }列表页根据状态显示不同样式未拆的红包显示开已拆的红包显示金额正在拆的红包禁用点击。弹窗内部只关心opening到opened这一段动画逻辑清晰很多。3.2 拆包动画旋转、缩放、透明度三管齐下真实的拆红包体验核心是从中间裂开、金光一闪、金额浮现三个动作。我用一个AnimationController把整个动画拆成三段编排。late AnimationController _controller; late Animationdouble _flipAnimation; late Animationdouble _scaleAnimation; late Animationdouble _fadeAnimation; override void initState() { super.initState(); _controller AnimationController( duration: const Duration(milliseconds: 900), vsync: this, ); _flipAnimation TweenSequencedouble([ TweenSequenceItem(tween: Tween(begin: 0.0, end: 0.5).chain(CurveTween(curve: Curves.easeOut)), weight: 40), TweenSequenceItem(tween: Tween(begin: 0.5, end: 0.0).chain(CurveTween(curve: Curves.elasticOut)), weight: 60), ]).animate(_controller); _scaleAnimation Tweendouble(begin: 1.0, end: 1.15) .chain(CurveTween(curve: Curves.easeOutBack)) .animate(_controller); _fadeAnimation CurvedAnimation(parent: _controller, curve: const Interval(0.5, 1.0, curve: Curves.easeIn)); }动画的原理不复杂前半段红包上半部分像翻盖一样转开后半段整体轻微放大再回弹同时金额淡入。所有的运动和透明度都挂在同一个控制器上只是Interval和Curve不同这样节奏才能统一。3.3 弹簧手感从哪来微信红包那个开按钮按下去的反馈并不只是变亮一下而是有一种被拉开的阻尼感。Flutter 里最接近这种手感的是Curves.elasticOut和Curves.overshoot。我实测下来elasticOut用于翻转回弹最自然easeOutBack用于整体放大有轻微的过头再收回非常接近真实的物理反馈。有一点要注意动画时长不要拉太长。我试过 1.5 秒以上的版本效果很拖沓900 毫秒左右刚好既有存在感又不会让用户等得烦躁。4. 随机金额分配算法微信红包的核心逻辑4.1 先理解二倍均值法红包金额随机分配是个老生常谈的话题网上流传最广的算法叫二倍均值法。原理一句话讲清楚每次从剩余金额的平均值里取两倍作为随机上限在这个范围内随机生成一个金额这样保证每次分出去的钱不会过大也不会过小。举个例子总共 100 元分 5 个红包。第一次剩余平均值是 20 元两倍是 40 元在 0.01 到 40 元之间随机取一个数。假设取到 15 元剩余 85 元、4 个红包平均值变成 21.25 元再在 0.01 到 42.5 元之间随机取。这样越到后面越不会出现最后一个人拿 0.01 元的情况。4.2 用 Dart 实现并处理精度坑直接按这个思路写代码会遇到一个经典问题浮点精度。0.1 加 0.2 在 Java、Dart、JavaScript 里都不能精确等于 0.3累加下去误差会被放大。我直接在每次计算金额后做一次toStringAsFixed(2)再转回 double这样保证每个金额都只保留两位小数。Listdouble splitRedPacket(double total, int count, {double minAmount 0.01}) { if (count 0 || total count * minAmount) { throw ArgumentError(总金额不足或个数不合法); } final random Random.secure(); final result double[]; double remain total; for (var i count; i 1; i--) { final maxAmount remain / i * 2; // 随机区间[minAmount, maxAmount] final safeMax maxAmount minAmount ? minAmount : maxAmount; double amount minAmount random.nextDouble() * (safeMax - minAmount); amount double.parse(amount.toStringAsFixed(2)); remain - amount; result.add(amount); } // 最后一个红包拿剩余金额 result.add(double.parse(remain.toStringAsFixed(2))); return result; }这里Random.secure()是我特意换的。普通随机数生成器在安全要求高的场景下不够随机红包金额虽然不是安全场景但用secure能避免一些极端情况下连续生成相同金额的问题。实际测下来分配出的金额分布比较均匀不会出现第一个包直接拿走 80% 的情况。注意微信红包的真实算法比这个复杂得多它会考虑并发抢包、金额单位分、发红包时的预扣款等。这里做的是单机模拟版仅用于学习算法思路不保证和真实产品行为完全一致。4.3 边界条件测试算法写完了还要测边界。我最常测的三种情况总额 0.01 元分 1 个。输出应该是 0.01。总额 0.02 元分 2 个。两个红包都应该大于等于 0.01。总数 100 元分 100 个。所有金额之和必须等于 100分毫不差。我用一个简单的断言循环跑了几百次确认没有出现负数、没有出现金额总和偏差超过 0.01 的情况。边界测试的好处是后面加功能时不会因为浮点误差搞出一堆诡异 bug。5. 数据层与状态管理让项目不再是一次性 Demo5.1 本地假数据怎么设计第一版不接后端所以我要在本地造一批数据来模拟真实场景。每个红包模型包含id发送人名字祝福语金额拆开后才有状态idle / opening / opened创建时间为了更像回事我把发送人名字做成一个列表张三、李四、王五……每次进入页面随机生成几个红包。这样列表刷出来不至于千篇一律演示效果也更自然。5.2 用一个 ChangeNotifier 管理全局状态拆红包的状态不是某一个页面自己的事。列表页要刷新显示、弹窗要控制动画、结果页要展示金额这三处必须共享同一份数据。我用一个RedPacketStore extends ChangeNotifier来持有红包列表和当前选中的红包。点击开按钮时流程是把当前红包状态改成 opening通知监听者。弹窗内播放动画。动画结束后调用算法生成金额写入当前红包状态改成 opened。保存到本地再次通知。列表页根据新的状态自动渲染成已拆开样式。所有状态变更都走同一个 store不会出现弹窗里显示已拆、列表里还是未拆的错位问题。5.3 持久化拆包记录我用shared_preferences把拆过的红包记录存成一个 JSON 数组key 用red_packet_records。核心代码就三行final prefs await SharedPreferences.getInstance(); final json jsonEncode(records.map((e) e.toJson()).toList()); await prefs.setString(red_packet_records, json);启动时读取、反序列化、灌回 store就完成了简单的持久化。这个方案虽然简陋但作为第一版足够了。后续如果要做多用户或者上云再换成数据库或者接口不迟。6. 开发中踩过的坑与排查实录6.1 RenderFlex overflow小屏上的黄黑条第一次在窄屏手机上跑列表页按钮区域的文字直接溢出屏边缘出现经典的黄黑条纹。解决办法是给按钮文字加上FittedBox让它在空间不足时自动缩放而不是硬排版。child: FittedBox( fit: BoxFit.scaleDown, child: Text( 拆红包, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), ), )如果你在手机上看到某段文字被截断第一步先看是不是溢出了别急着改字号。6.2 AnimationController 生命周期管理这是初学者最容易漏的在State里创建了AnimationController却忘了在dispose()里释放。开发时内存泄漏不明显但页面反复打开关闭后动画会越来越卡甚至直接崩溃。override void dispose() { _controller.dispose(); super.dispose(); }养成习惯只要在initState里create就立刻到dispose里dispose。6.3 double 精度导致钱对不上账我第一次验证算法时发现所有金额加起来和总额差了一分钱。排查半天发现是remain在多次相减后产生了微小的浮点误差。解决办法就是我在算法里写的每个金额都toStringAsFixed(2)最后一次金额也做同样的处理。这个坑如果你不手写算法可能永远遇不到但一旦做了就一定要重视。6.4 锯齿边缘在部分屏幕上发虚CustomPainter 画锯齿时如果Paint没有打开抗锯齿在 iPad 真机上看起来会毛毛的。解决方法是设置isAntiAlias true另外shouldRepaint别偷懒——颜色、数量、深度任何一个变了都要触发重绘否则切主题时会出现旧画面残留。7. 你还可以在这个基础上继续加什么我个人觉得这个项目做到列表 - 弹窗 - 动画 - 金额 - 持久化闭环已经是一份拿得出手的实战练习了。如果你做完第一版觉得不过瘾我建议按这个顺序扩展加一个查看开包记录的历史页面让数据有更完整的流动路径。把静态动画升级成可以配置多个动画模板比如不同金额区间显示不同配色。换一套状态管理方案比如用 Riverpod 或者 Bloc体验一下不同方案在同一个项目里的差异。最后分享一个我自己做这类仿制项目的心得不要追求 100% 还原七八分像就够了。真正值钱的是你为了那七八分像把绘制、动画、算法、状态管理的底子都过了一遍。仿到哪个细节觉得原来如此这个项目的目的就达到了。本文还有配套的精品资源点击获取