起因
那天我在 AppStore 晃荡寻求灵感,翻来翻去索然无味,看着工具榜单有个木鱼。
那我寻思就搜搜看...
一看,嚯!这小玩意也还是挺招人喜欢的。

于是打定主意,就 完整的做一整套上架到市场 玩玩,打通一下整个 App 上架流程是什么样的。
实践
一. 准备阶段
1. 软件设计
1.1 页面构思

1.2 开发语言
作为一个简单的 App,同时又是 跨平台 的。
因此我们需要:
- 缩短开发时间
- 提升开发效率
- 一次开发,多平台使用
毫无疑问,我们选择了 Flutter, 尽管在 性能上必然不如原生 ,到那时对于自由开发者,优势在我。

☘️ 什么是 Flutter ?
Flutter 是由 Google 推出的跨平台 UI 框架,具备同时开发 Android 与 iOS 应用的显著优势。首先,它>用 Dart 语言与自绘引擎(Skia),能够在两端实现一致的界面与高性能渲染,避免原生界面差异带来的适配问题。其次,Flutter 提供丰富的组件库和热重载(Hot Reload)功能,大幅提升开发效率和调试体验。相较于传统的双端分别开发,Flutter 可通过一套代码实现多平台部署,降低开发与维护成本。同时,其良好的社区生态和插件支持,方便集成相机、定位、蓝牙等原生功能,满足复杂业务需求。综合来看,Flutter 在性能、效率和统一体验方面兼具优势,是移动应用跨平台开发的理想方案。
2.素材收集
2.1 兄弟网站 借
浏览器上搜一搜,木鱼的网站还是很多的。
借一下它们的 图片 和 音效 🙏。


2.1 专业网站 取
除了借,在这个 iconfont 网站上,还是有非常多的图标,我们也用上一用。
💡 在此也把这个网站分享给大家:www.iconfont.cn/

二. 开发阶段

话不多说,直接 Trae 启动!
虽说用trae很多基础代码文件,不需要手写了,甚至第一版也都是直接可用的。
但是 万丈高楼平地起,房子精装靠自己。
接下来就介绍一下 核心模块。
1. 敲木鱼
1.1 轻微缩放
🤔 你问我为什么保持缩放,因为只有这样别人才知道,你敲的是木鱼。

dart
return GestureDetector(
onTap: onTap,
child: AnimatedScale(
scale: tapped ? 0.9 : 1.0,
duration: const Duration(milliseconds: 100),
child: flipHorizontal
? Transform(
alignment: Alignment.center,
transform: Matrix4.identity()..scale(-1.0, 1.0, 1.0),
child: base,
)
: base,
),
);
1.2 播放音效
👻 每一次伴随着点击,直触你灵魂的,是这音效。
dart
Future<void> playKnock() async {
try {
// 获取选择音效
final asset = StorageService().readAudioAsset();
// 每次播放前设置当前音量
final volume = StorageService().readMusicVolume();
await _player.setVolume(volume);
await _player.play(AssetSource(asset));
} catch (_) {
// 音频资源不存在或加载失败时忽略,不影响交互
}
}
1.3 漂浮文字
👅 漂浮的不是文字,而是文化的韵味。
|-----------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------|
|
固定位置,单浮现 |
固定位置,多浮现 |
|
起点变动,多浮现 |
始末双变,多浮现 |
多重文字 和 随机偏移值 对漂浮效果的影响
1.3.1 漂浮文字组件
dart
/// 漂浮文字组件 ------ 用于显示短暂浮动的文字效果(如"功德+1"、"点赞"等)
class FloatingText extends StatefulWidget {
/// 是否显示该文字(true 时触发动画)
final bool visible;
/// 需要显示的文字内容
final String text;
const FloatingText({
super.key,
required this.visible,
required this.text,
});
@override
State<FloatingText> createState() => _FloatingTextState();
}
class _FloatingTextState extends State<FloatingText>
with SingleTickerProviderStateMixin {
/// 动画控制器 ------ 控制动画时间、播放进度
late final AnimationController _controller =
AnimationController(
vsync: this, // 使用当前 State 作为动画的 Ticker
duration: const Duration(milliseconds: 1300), // 动画时长:1.3 秒
);
/// 位移动画 ------ 让文字从下往上随机方向漂浮
late final Animation<Offset> _offset = Tween(
// 起点:x 在 [-0.5, 0.5] 之间随机,y 从 0.5 开始(相对位置)
begin: Offset(Random().nextDouble() - 0.5, 0.5),
// 终点:x 同样随机,y 向上漂浮(-5.0 到 -4.0 之间)
end: Offset(Random().nextDouble() - 0.5, Random().nextDouble() - 5.0),
).animate(
// 曲线动画,使用 easeOut(先快后慢)
CurvedAnimation(
parent: _controller,
curve: Curves.easeOut,
),
);
@override
void initState() {
super.initState();
// 当 visible 为 true 时,组件创建后立即播放动画
if (widget.visible) {
_controller.forward(from: 0);
}
}
@override
void dispose() {
// 释放动画资源,防止内存泄漏
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return IgnorePointer(
// 忽略点击事件,不阻挡底层操作(比如用户点击其他区域)
child: FadeTransition(
// 使用控制器的值作为透明度动画
opacity: _controller,
child: SlideTransition(
// 使用上面定义的偏移动画实现漂浮
position: _offset,
child: Text(
widget.text,
style: TextStyle(
fontSize: 20,
color: Theme.of(context)
.colorScheme
.primary
.withOpacity(0.5), // 半透明的主色文字
),
),
),
),
);
}
}
-
随机漂浮方向
使用
Random().nextDouble() - 0.5生成随机的水平偏移,使文字每次漂浮方向略有不同,视觉上更自然。 -
组合动画
FadeTransition控制透明度(随时间逐渐消失)SlideTransition控制位移(从下往上飘)
两者叠加形成"漂浮消散"的动画效果。
-
生命周期控制
initState():检测visible是否为true,如果是,则立即执行动画。dispose():动画结束后释放控制器,避免内存泄漏。
-
无交互干扰
外层用
IgnorePointer,确保漂浮文字不拦截触摸事件(例如点击按钮或屏幕其他部分仍可响应)。
1.3.2 多重文字浮现
dart
/// 漂浮文字的数据模型:用于在 UI 上显示诸如"功德+1"等文案
/// - `id`:唯一标识,用于定时移除和列表匹配
/// - `text`:显示的文案内容
class BlessItem {
final int id;
final String text;
BlessItem(this.id, this.text);
}
/// 当前屏幕上正在显示的漂浮文字列表(响应式集合)
/// UI 通过 `Obx` 监听此列表并将每个项渲染成 `FloatingText`
final RxList<BlessItem> blessList = <BlessItem>[].obs;
/// 递增计数器,用于为每个 `BlessItem` 分配唯一的 `id`
/// 非持久化,仅用于本次会话的移除匹配(应用重启会归零)
int wc = 0;
/// 新增一个漂浮文字
void showBlessText(String text) {
final id = wc++;
blessList.add(BlessItem(id, text));
// 动画播放完自动移除(可根据 FloatingText 动画时长调整)
Future.delayed(const Duration(milliseconds: 2000), () {
blessList.removeWhere((e) => e.id == id);
});
}
- 使用
blessList承载每次敲击生成的漂浮文字,每次调用showBlessText(popupText.value)都会向列表追加一个新项。 - 通过
blessList.map(...)将列表里的所有项都渲染出来,不会等旧的消失才渲染新的,因此同一时刻会出现多个功德+1同时漂浮。 - 每个漂浮文字在
showBlessText中被设定延迟2000ms后移除,在这 2 秒有效期内,所有新增的漂浮文字会并存。
2. 抽奖
抽奖 这也算是木鱼界一点点小小创新。
大家在敲击木鱼时有几率中奖,可以去抽奖页面抽皮肤。

2.1 多彩木鱼生成
dart
/// 点击按钮后执行随机上色
void applyRandomColorize() {
// 若原始图片不存在则直接返回
if (_original == null) return;
// 🎲 随机决定颜色数量(即渐变色数量)
// 概率分布:
// 1色:70%
// 2色:15%
// 3色:10%
// 4色:5%
final int roll = _random.nextInt(100);
int count;
if (roll < 70) {
count = 1;
} else if (roll < 85) {
count = 2;
} else if (roll < 95) {
count = 3;
} else {
count = 4;
}
// 🎲 随机决定渐变方向:垂直 or 水平
final bool isVertical = _random.nextBool();
// 🎨 随机生成指定数量的亮色
final List<Color> colors = List<Color>.generate(count, (_) => _genBright());
// ⚙️ 调用图像处理函数,对白色区域进行上色
final img.Image out = _colorizeWhite(
_original!,
stops: colors,
vertical: isVertical,
);
// 将结果图片转为 PNG 格式的字节流,用于显示或保存
final Uint8List bytes = Uint8List.fromList(img.encodePng(out));
// 更新 UI 响应变量
outputPng.value = bytes; // 结果图像
vertical.value = isVertical; // 当前方向
stops.assignAll(colors); // 当前使用的渐变色
}
/// 随机生成亮色
Color _genBright() {
// ch(min):生成 [min, 255] 范围内的随机数,用于控制亮度下限
int ch(int min) => min + _random.nextInt(256 - min);
int r = ch(80), g = ch(80), b = ch(80);
// 随机挑选一个主色通道(R/G/B)强化,增加饱和度
switch (_random.nextInt(3)) {
case 0:
r = ch(160);
break;
case 1:
g = ch(160);
break;
default:
b = ch(160);
}
// 返回随机亮色(ARGB 模式)
return Color.fromARGB(255, r, g, b);
}
/// 白色区域上色
img.Image _colorizeWhite(
img.Image src, {
required List<Color> stops,
required bool vertical,
}) {
final int w = src.width;
final int h = src.height;
// 创建副本,防止修改原图
final img.Image out = img.Image.from(src);
final int denomX = (w - 1) <= 0 ? 1 : (w - 1);
final int denomY = (h - 1) <= 0 ? 1 : (h - 1);
/// 内部函数:根据 t 值采样渐变颜色
Color sample(double t) {
// 若只有一个颜色,直接返回
if (stops.length == 1) return stops[0];
// 渐变分段数量 = 颜色数 - 1
final int segments = stops.length - 1;
final double segLen = 1.0 / segments;
// 当前 t 所在分段索引
int idx = (t ~/ segLen);
if (idx >= segments) idx = segments - 1;
// 计算在当前分段内的相对位置 [0~1]
final double localT = ((t - idx * segLen) / segLen).clamp(0.0, 1.0);
// 线性插值计算颜色分量
final Color a = stops[idx];
final Color b = stops[idx + 1];
final int rr = (a.red + (b.red - a.red) * localT).round();
final int gg = (a.green + (b.green - a.green) * localT).round();
final int bb = (a.blue + (b.blue - a.blue) * localT).round();
return Color.fromARGB(255, rr, gg, bb);
}
// 遍历像素,检测是否为白色区域
for (int y = 0; y < h; y++) {
for (int x = 0; x < w; x++) {
// t 用于控制渐变方向与进度
final double t = vertical
? (y / denomY).clamp(0.0, 1.0)
: (x / denomX).clamp(0.0, 1.0);
final Color c = sample(t); // 取对应渐变颜色
// 获取当前像素
final img.Pixel p = out.getPixel(x, y);
final int r = p.r.toInt();
final int g = p.g.toInt();
final int b = p.b.toInt();
final int a = p.a.toInt();
// 判断是否接近纯白
final bool isWhite = a > 8 && r > 240 && g > 240 && b > 240;
// 若是白色像素,则替换成渐变颜色
if (isWhite) {
out.setPixelRgba(x, y, c.red, c.green, c.blue, a);
}
}
}
return out;
}
| 步骤 | 说明 |
|---|---|
| ① | 随机生成 1~4 种亮色 |
| ② | 随机选择渐变方向(垂直或水平) |
| ③ | 扫描图片每个像素,判断是否为接近白色的区域 |
| ④ | 对这些白色像素按渐变比例替换为对应的彩色像素 |
| ⑤ | 最后输出处理后的新图像(PNG 格式) |
2.2 抽奖逻辑
dart
// 每 10000 次必出,其余每次 1% 概率触发抽取
void _maybeTriggerDraw() {
final total = _storage.readCounter();
final guaranteed = total > 0 && total % 10000 == 0;
final chance = Random().nextInt(1000) == 0; // 0.1%
if (guaranteed || chance) {
_storage.addSkin();
Get.snackbar('恭喜!', '获取抽取皮肤次数+1 !');
}
}
三. 上架阶段
3.1 阿里云备案

3.2 苹果商店上架

阿里云 与 苹果 的备案上架,由于是第一次不太懂,也是麻烦多多。
好在最后也是克服问题,氪 服 🍎($99开发者账户)。

总结

木鱼 App 最终效果
这就是最终真机演示效果,app store 备案通过后也即将上线。
先给大家一个网页版,尝尝咸淡。
感谢大家支持🙏🙏🙏
在线爽敲🐠:muyu.shanchen.space/
个人门户🧑🎓:www.shanchen.com