从 0 到上架:用 Flutter 一天做一款功德木鱼

起因

那天我在 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

相关推荐
外公的虱目鱼2 小时前
基于vue-cli前端组件库搭建
前端·vue.js
嚴寒2 小时前
2025最终!Mac配置Flutter全平台开发环境完整指南(亲测有效)
前端·flutter
hi大雄2 小时前
如何用Claude Code 生成顶级UI ❇️
前端
拖拉斯旋风2 小时前
深入理解 CSS 选择器的底层逻辑:从层叠到优先级的本质
前端·css
半桶水专家2 小时前
npm run 的工作原理和工作流程
前端·npm·node.js
北辰浮光2 小时前
npm install core-js不成功
前端·javascript·npm
东华帝君3 小时前
React源码解读
前端
Mintopia3 小时前
🌱 AIGC 技术的轻量化趋势:Web 端“小而美”模型的崛起
前端·javascript·aigc
开发者小天3 小时前
React中的useRef的用法
开发语言·前端·javascript·react.js