Contents

打造一个懂鸟App:从零到一的完整开发指南

立项思考:为什么要做一个"懂鸟"App?

市面上已有 Merlin Bird ID(康奈尔出品,全球最强)、懂鸟(国内最好的观鸟App)、花伴侣(植物识别兼带鸟类)。再做一个有什么意义?

答案是:它们都太"工具化"了。

App 优势 不足
Merlin 识别精度极高,鸟声识别无敌 中文体验差,无社交,无本土化知识
懂鸟 本土化好,有社区 识别精度一般,UI较旧
花伴侣 通用识别 鸟类不够专业

我们要做的是一个懂你的观鸟伙伴——不仅识别,还能学习、记录、社交。核心差异化:

  1. AI对话式交互:不是"拍照→出结果"的冷冰冰工具,而是"你拍的这只鸟好漂亮,它是白鹭,我来给你讲讲它的故事"
  2. 个性化学习路径:根据你的观鸟水平和所在地区,推荐该认识哪些鸟
  3. 观鸟日记自动生成:不用手动记录,App自动帮你整理

技术架构

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
┌─────────────────────────────────────────────────────────────┐
│                      懂鸟 App (Flutter)                      │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ┌─────────┐  ┌──────────┐  ┌──────────┐  ┌────────────┐ │
│  │ 拍照识鸟 │  │ 声音识别  │  │ 鸟类图鉴  │  │  观察日记   │ │
│  └────┬────┘  └────┬─────┘  └────┬─────┘  └─────┬──────┘ │
│       │            │             │               │          │
│  ┌────▼────────────▼─────────────▼───────────────▼──────┐ │
│  │                   本地推理引擎                         │ │
│  │  ┌──────────┐  ┌───────────┐  ┌──────────────────┐  │ │
│  │  │ BirdNET  │  │ TFLite    │  │  SQLite + 向量索引│  │ │
│  │  │ (声音)   │  │ (图像)    │  │  (离线知识库)     │  │ │
│  │  └──────────┘  └───────────┘  └──────────────────┘  │ │
│  └──────────────────────────────────────────────────────┘ │
│                          │                                 │
│  ┌───────────────────────▼──────────────────────────────┐ │
│  │                   云端服务                             │ │
│  │  ┌──────────┐  ┌───────────┐  ┌──────────────────┐  │ │
│  │  │ AI API   │  │ 用户系统   │  │  社区内容        │  │ │
│  │  └──────────┘  └───────────┘  └──────────────────┘  │ │
│  └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘

技术选型

模块 技术方案 理由
跨平台框架 Flutter 3.x 一套代码覆盖iOS/Android,性能好
本地AI TFLite + BirdNET 离线识别,无网络也能用
云端AI GPT-5.4 API 复杂问答和自然语言交互
本地数据库 SQLite + drift 结构化存储,支持复杂查询
向量搜索 ObjectBox / isar 离线向量检索,鸟类知识语义搜索
状态管理 Riverpod 类型安全,适合中大型项目
后端 Supabase BaaS,免运维,免费额度够用

第一步:Flutter项目搭建

1
2
3
4
5
6
flutter create dongniao
cd dongniao
flutter pub add flutter_riverpod drift sqflite
flutter pub add image_picker camera
flutter pub add http dio
flutter pub add flutter_sound audioplayers

项目结构

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
lib/
├── main.dart
├── app/
   └── app.dart                    # MaterialApp配置
├── core/
   ├── theme/
      └── app_theme.dart          # 主题:自然绿色调
   ├── constants/
      └── bird_species.dart       # 鸟类数据常量
   └── utils/
       └── image_utils.dart        # 图片预处理
├── features/
   ├── identification/             # 识别功能
      ├── data/
         ├── birdnet_service.dart
         └── vision_service.dart
      ├── presentation/
         ├── camera_screen.dart
         └── result_screen.dart
      └── widgets/
          └── identification_card.dart
   ├── sound/                      # 声音识别
      ├── data/
         └── sound_recorder.dart
      └── presentation/
          └── sound_screen.dart
   ├── field_guide/                # 鸟类图鉴
      ├── data/
         └── bird_database.dart
      ├── presentation/
         ├── guide_screen.dart
         └── species_detail.dart
      └── widgets/
          └── bird_card.dart
   ├── journal/                    # 观察日记
      ├── data/
         └── journal_repository.dart
      ├── presentation/
         ├── journal_screen.dart
         └── journal_detail.dart
      └── widgets/
          └── observation_tile.dart
   └── chat/                       # AI对话
       ├── data/
          └── chat_service.dart
       └── presentation/
           └── chat_screen.dart
└── shared/
    ├── models/
       ├── bird_species.dart
       └── observation.dart
    └── services/
        └── local_ai_service.dart

第二步:核心功能实现

2.1 拍照识鸟

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
// lib/features/identification/presentation/camera_screen.dart
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class CameraScreen extends ConsumerStatefulWidget {
  const CameraScreen({super.key});

  @override
  ConsumerState<CameraScreen> createState() => _CameraScreenState();
}

class _CameraScreenState extends ConsumerState<CameraScreen> {
  final ImagePicker _picker = ImagePicker();
  bool _isProcessing = false;

  Future<void> _takePhoto() async {
    final XFile? photo = await _picker.pickImage(
      source: ImageSource.camera,
      maxWidth: 1024,
      maxHeight: 1024,
      imageQuality: 85,
    );
    
    if (photo == null) return;
    
    setState(() => _isProcessing = true);
    
    try {
      // 调用本地AI识别
      final result = await ref.read(
        birdIdentificationProvider(photo.path).future
      );
      
      if (mounted) {
        Navigator.push(
          context,
          MaterialPageRoute(
            builder: (_) => ResultScreen(identification: result),
          ),
        );
      }
    } finally {
      if (mounted) setState(() => _isProcessing = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.black,
      body: Stack(
        children: [
          // 相机预览(实际项目用camera包)
          const Center(
            child: Icon(Icons.camera_alt, size: 100, color: Colors.white54),
          ),
          
          // 底部操作栏
          Positioned(
            bottom: 40,
            left: 0,
            right: 0,
            child: Row(
              mainAxisAlignment: MainAxisAlignment.spaceEvenly,
              children: [
                // 相册选图
                IconButton(
                  iconSize: 32,
                  color: Colors.white,
                  icon: const Icon(Icons.photo_library),
                  onPressed: _pickFromGallery,
                ),
                
                // 拍照按钮
                GestureDetector(
                  onTap: _isProcessing ? null : _takePhoto,
                  child: Container(
                    width: 80,
                    height: 80,
                    decoration: BoxDecoration(
                      shape: BoxShape.circle,
                      border: Border.all(color: Colors.white, width: 4),
                      color: _isProcessing 
                          ? Colors.white30 
                          : Colors.white,
                    ),
                    child: _isProcessing
                        ? const CircularProgressIndicator(color: Colors.green)
                        : null,
                  ),
                ),
                
                // 声音识别
                IconButton(
                  iconSize: 32,
                  color: Colors.white,
                  icon: const Icon(Icons.mic),
                  onPressed: () => Navigator.pushNamed(context, '/sound'),
                ),
              ],
            ),
          ),
          
          // 提示文字
          Positioned(
            top: 60,
            left: 0,
            right: 0,
            child: Center(
              child: Container(
                padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
                decoration: BoxDecoration(
                  color: Colors.black54,
                  borderRadius: BorderRadius.circular(20),
                ),
                child: const Text(
                  '📸 对准鸟类拍照识别',
                  style: TextStyle(color: Colors.white, fontSize: 16),
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

2.2 BirdNET本地推理服务

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// lib/shared/services/local_ai_service.dart
import 'dart:io';
import 'package:tflite_flutter/tflite_flutter.dart';

class LocalAIService {
  static const String _birdnetModelPath = 'assets/models/BirdNET Classifier.tflite';
  static const String _labelsPath = 'assets/models/BirdNET Labels.txt';
  
  Interpreter? _interpreter;
  List<String> _labels = [];
  
  /// 初始化模型
  Future<void> init() async {
    _interpreter = await Interpreter.fromAsset(_birdnetModelPath);
    final labelsFile = await DefaultAssetBundle.of(/* context */)
        .loadString(_labelsPath);
    _labels = labelsFile.split('\n').where((l) => l.isNotEmpty).toList();
  }
  
  /// 图像识别
  Future<BirdIdentificationResult> identifyBird(File imageFile) async {
    // 1. 预处理图片
    final input = await _preprocessImage(imageFile);
    
    // 2. 推理
    final output = List.filled(1 * _labels.length, 0.0).reshape(
      [1, _labels.length]
    );
    _interpreter!.run(input, output);
    
    // 3. 解析结果
    final scores = output[0] as List<double>;
    final indexedScores = scores.asMap().entries.toList()
      ..sort((a, b) => b.value.compareTo(a.value));
    
    final topResults = indexedScores.take(5).map((entry) {
      return BirdCandidate(
        name: _labels[entry.key],
        confidence: entry.value,
      );
    }).toList();
    
    return BirdIdentificationResult(
      topBird: topResults.first,
      allCandidates: topResults,
    );
  }
  
  /// 预处理图片到模型输入格式
  Future<List<List<List<List<double>>>>> _preprocessImage(
    File imageFile
  ) async {
    // 224x224 RGB,归一化到[-1,1]
    // 实际实现用 image 包处理
    // ...
  }
}

2.3 鸟类图鉴数据库

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
// lib/features/field_guide/data/bird_database.dart
import 'package:drift/drift.dart';

part 'bird_database.g.dart';

/// 鸟类物种表
class BirdSpecies extends Table {
  IntColumn get id => integer().autoIncrement()();
  TextColumn get nameCn => text()();            // 中文名
  TextColumn get nameEn => text()();            // 英文名
  TextColumn get scientificName => text()();    // 学名
  TextColumn get family => text()();            // 科
  TextColumn get orderName => text()();         // 目
  TextColumn get habitat => text()();           // 栖息地
  TextColumn get distribution => text()();      // 分布
  TextColumn get diet => text()();              // 食性
  TextColumn get conservationStatus => text()(); // 保护级别
  TextColumn get description => text()();       // 描述
  TextColumn get imageUrl => text()();          // 图片URL
  TextColumn get voiceDescription => text()();  // 叫声描述
  BoolColumn get isFavorite => boolean().withDefault(const Constant(false))();
  IntColumn get viewCount => integer().withDefault(const Constant(0))();
}

/// 观察记录表
class Observations extends Table {
  IntColumn get id => integer().autoIncrement()();
  IntColumn get speciesId => integer().references(BirdSpecies, #id)();
  RealColumn get latitude => real()();
  RealColumn get longitude => real()();
  TextColumn get imagePath => text()();
  TextColumn get notes => text().nullable()();
  IntColumn get confidence => integer()();
  DateTimeColumn get observedAt => dateTime()();
  DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
}

/// 用户观察统计表
class UserStats extends Table {
  TextColumn get userId => text()();
  IntColumn get totalObservations => integer().withDefault(const Constant(0))();
  IntColumn get totalSpecies => integer().withDefault(const Constant(0))();
  IntColumn get streakDays => integer().withDefault(const Constant(0))();
  TextColumn get lastObservationDate => text().nullable()();
}

@DriftDatabase(tables: [BirdSpecies, Observations, UserStats])
class BirdDatabase extends _$BirdDatabase {
  BirdDatabase() : super(_openConnection());
  
  @override
  int get schemaVersion => 1;
  
  /// 搜索鸟类
  Future<List<BirdSpeciesData>> searchBirds(String query) async {
    return (select(birdSpecies)
      ..where((t) => t.nameCn.contains(query) | t.nameEn.contains(query))
      ..limit(20))
      .get();
  }
  
  /// 按科分类浏览
  Future<List<BirdSpeciesData>> getBirdsByFamily(String family) async {
    return (select(birdSpecies)
      ..where((t) => t.family.equals(family))
      ..orderBy([(t) => OrderingTerm.asc(t.nameCn)]))
      .get();
  }
  
  /// 获取所有科(用于分类浏览)
  Future<List<String>> getAllFamilies() async {
    final query = selectOnly(birdSpecies)
      ..addColumns([birdSpecies.family])
      ..groupBy([birdSpecies.family]);
    final results = await query.get();
    return results.map((r) => r.read(birdSpecies.family)!).toList();
  }
  
  /// 记录一次观察
  Future<int> addObservation(ObservationsCompanion observation) async {
    return into(observations).insert(observation);
  }
  
  /// 获取观察历史
  Future<List<ObservationWithBird>> getObservationHistory({
    int limit = 50,
    int offset = 0,
  }) async {
    final query = select(observations).join([
      innerJoin(birdSpecies, birdSpecies.id.equalsExp(observations.speciesId)),
    ])
      ..orderBy([OrderingTerm.desc(observations.observedAt)])
      ..limit(limit, offset: offset);
    
    return query.map((row) {
      return ObservationWithBird(
        observation: row.readTable(observations),
        species: row.readTable(birdSpecies),
      );
    }).get();
  }
  
  /// 获取统计数据
  Future<UserStatistics> getStats() async {
    final totalObs = await customSelect(
      'SELECT COUNT(*) as count FROM observations',
    ).getSingle();
    
    final totalSpecies = await customSelect(
      'SELECT COUNT(DISTINCT species_id) as count FROM observations',
    ).getSingle();
    
    final recentSpecies = await customSelect(
      '''SELECT species_id, COUNT(*) as count, 
         MAX(observed_at) as last_seen
         FROM observations 
         GROUP BY species_id 
         ORDER BY count DESC 
         LIMIT 10'''
    ).get();
    
    return UserStatistics(
      totalObservations: totalObs.read<int>('count'),
      totalSpecies: totalSpecies.read<int>('count'),
      topSpecies: recentSpecies.map((r) {
        return SpeciesStat(
          speciesId: r.read<int>('species_id'),
          count: r.read<int>('count'),
          lastSeen: r.read<String>('last_seen'),
        );
      }).toList(),
    );
  }
}

2.4 AI对话界面

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
// lib/features/chat/presentation/chat_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class ChatMessage {
  final String role; // 'user' or 'assistant'
  final String content;
  final DateTime timestamp;
  final String? imagePath; // 用户发送的图片

  ChatMessage({
    required this.role,
    required this.content,
    DateTime? timestamp,
    this.imagePath,
  }) : timestamp = timestamp ?? DateTime.now();
}

class ChatScreen extends ConsumerStatefulWidget {
  final String? initialBirdSpecies;
  
  const ChatScreen({super.key, this.initialBirdSpecies});

  @override
  ConsumerState<ChatScreen> createState() => _ChatScreenState();
}

class _ChatScreenState extends ConsumerState<ChatScreen> {
  final TextEditingController _controller = TextEditingController();
  final ScrollController _scrollController = ScrollController();
  List<ChatMessage> _messages = [];
  bool _isLoading = false;

  @override
  void initState() {
    super.initState();
    // 如果有初始鸟类信息,自动发送欢迎消息
    if (widget.initialBirdSpecies != null) {
      _messages.add(ChatMessage(
        role: 'assistant',
        content: '你好!我看到你拍到了一只${widget.initialBirdSpecies},'
            '想了解什么?可以问我关于它的习性、分布、保护级别等问题。',
      ));
    }
  }

  Future<void> _sendMessage() async {
    final text = _controller.text.trim();
    if (text.isEmpty) return;

    setState(() {
      _messages.add(ChatMessage(role: 'user', content: text));
      _controller.clear();
      _isLoading = true;
    });

    _scrollToBottom();

    try {
      // 构建对话上下文
      final conversationHistory = _messages.map((m) => {
        'role': m.role,
        'content': m.content,
      }).toList();

      final response = await ref.read(
        chatServiceProvider({
          'messages': conversationHistory,
          'context': widget.initialBirdSpecies ?? '',
        }).future,
      );

      setState(() {
        _messages.add(ChatMessage(role: 'assistant', content: response));
        _isLoading = false;
      });
    } catch (e) {
      setState(() {
        _messages.add(ChatMessage(
          role: 'assistant',
          content: '抱歉,我暂时无法回答这个问题。请稍后再试。',
        ));
        _isLoading = false;
      });
    }

    _scrollToBottom();
  }

  void _scrollToBottom() {
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (_scrollController.hasClients) {
        _scrollController.animateTo(
          _scrollController.position.maxScrollExtent,
          duration: const Duration(milliseconds: 300),
          curve: Curves.easeOut,
        );
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('🐦 观鸟助手'),
        backgroundColor: const Color(0xFF2E7D32),
        actions: [
          IconButton(
            icon: const Icon(Icons.info_outline),
            onPressed: () => _showBirdInfo(),
          ),
        ],
      ),
      body: Column(
        children: [
          // 消息列表
          Expanded(
            child: ListView.builder(
              controller: _scrollController,
              padding: const EdgeInsets.all(16),
              itemCount: _messages.length + (_isLoading ? 1 : 0),
              itemBuilder: (context, index) {
                if (index == _messages.length) {
                  return const _TypingIndicator();
                }
                return _MessageBubble(message: _messages[index]);
              },
            ),
          ),
          
          // 输入栏
          Container(
            padding: const EdgeInsets.all(8),
            decoration: BoxDecoration(
              color: Colors.white,
              boxShadow: [
                BoxShadow(
                  color: Colors.black12,
                  blurRadius: 4,
                  offset: const Offset(0, -2),
                ),
              ],
            ),
            child: SafeArea(
              child: Row(
                children: [
                  // 图片按钮
                  IconButton(
                    icon: const Icon(Icons.image, color: Color(0xFF2E7D32)),
                    onPressed: () => _sendImage(),
                  ),
                  
                  // 输入框
                  Expanded(
                    child: TextField(
                      controller: _controller,
                      decoration: InputDecoration(
                        hintText: '问我关于鸟类的问题...',
                        border: OutlineInputBorder(
                          borderRadius: BorderRadius.circular(24),
                          borderSide: BorderSide.none,
                        ),
                        filled: true,
                        fillColor: Colors.grey[100],
                        contentPadding: const EdgeInsets.symmetric(
                          horizontal: 16, vertical: 10),
                      ),
                      maxLines: null,
                      textInputAction: TextInputAction.send,
                      onSubmitted: (_) => _sendMessage(),
                    ),
                  ),
                  
                  // 发送按钮
                  IconButton(
                    icon: const Icon(Icons.send, color: Color(0xFF2E7D32)),
                    onPressed: _isLoading ? null : _sendMessage,
                  ),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }
}

/// 消息气泡组件
class _MessageBubble extends StatelessWidget {
  final ChatMessage message;
  
  const _MessageBubble({required this.message});
  
  @override
  Widget build(BuildContext context) {
    final isUser = message.role == 'user';
    
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 4),
      child: Row(
        mainAxisAlignment: isUser 
            ? MainAxisAlignment.end 
            : MainAxisAlignment.start,
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          if (!isUser) ...[
            const CircleAvatar(
              backgroundColor: Color(0xFF2E7D32),
              child: Text('🐦', style: TextStyle(fontSize: 20)),
            ),
            const SizedBox(width: 8),
          ],
          
          Flexible(
            child: Container(
              padding: const EdgeInsets.all(12),
              decoration: BoxDecoration(
                color: isUser 
                    ? const Color(0xFF2E7D32) 
                    : Colors.grey[100],
                borderRadius: BorderRadius.only(
                  topLeft: const Radius.circular(16),
                  topRight: const Radius.circular(16),
                  bottomLeft: Radius.circular(isUser ? 16 : 4),
                  bottomRight: Radius.circular(isUser ? 4 : 16),
                ),
              ),
              child: Text(
                message.content,
                style: TextStyle(
                  color: isUser ? Colors.white : Colors.black87,
                  fontSize: 15,
                ),
              ),
            ),
          ),
          
          if (isUser) const SizedBox(width: 8),
        ],
      ),
    );
  }
}

2.5 声音识别功能

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
// lib/features/sound/presentation/sound_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_sound/flutter_sound.dart';

class SoundScreen extends StatefulWidget {
  const SoundScreen({super.key});

  @override
  State<SoundScreen> createState() => _SoundScreenState();
}

class _SoundScreenState extends State<SoundScreen> {
  final FlutterSoundRecorder _recorder = FlutterSoundRecorder();
  bool _isRecording = false;
  bool _isProcessing = false;
  Duration _duration = Duration.zero;

  @override
  void initState() {
    super.initState();
    _initRecorder();
  }

  Future<void> _initRecorder() async {
    await _recorder.openRecorder();
  }

  Future<void> _toggleRecording() async {
    if (_isRecording) {
      await _stopRecording();
    } else {
      await _startRecording();
    }
  }

  Future<void> _startRecording() async {
    await _recorder.startRecorder(
      toFile: 'bird_call_${DateTime.now().millisecondsSinceEpoch}.wav',
      codec: Codec.pcm16WAV,
      sampleRate: 48000,  // BirdNET要求48kHz
      numChannels: 1,     // 单声道
    );
    
    setState(() => _isRecording = true);
    
    // 录音计时器
    _recorder.onProgress?.listen((event) {
      setState(() => _duration = event.duration);
    });
  }

  Future<void> _stopRecording() async {
    final path = await _recorder.stopRecorder();
    setState(() {
      _isRecording = false;
      _isProcessing = true;
    });

    if (path != null) {
      // 调用BirdNET声音识别
      final result = await _identifySound(path);
      
      if (mounted) {
        _showResult(result);
      }
    }

    setState(() => _isProcessing = false);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: const Color(0xFF1B5E20),
      body: SafeArea(
        child: Column(
          children: [
            const SizedBox(height: 40),
            
            // 标题
            const Text(
              '🎤 听声辨鸟',
              style: TextStyle(
                fontSize: 28,
                fontWeight: FontWeight.bold,
                color: Colors.white,
              ),
            ),
            const SizedBox(height: 8),
            Text(
              _isRecording 
                  ? '正在录音... ${_formatDuration(_duration)}' 
                  : '点击按钮开始录音',
              style: const TextStyle(color: Colors.white70, fontSize: 16),
            ),
            
            const Spacer(),
            
            // 波形动画(录音时显示)
            if (_isRecording)
              SizedBox(
                height: 120,
                child: _AudioWaveform(duration: _duration),
              ),
            
            const Spacer(),
            
            // 录音按钮
            GestureDetector(
              onTap: _isProcessing ? null : _toggleRecording,
              child: Container(
                width: 120,
                height: 120,
                decoration: BoxDecoration(
                  shape: BoxShape.circle,
                  color: _isRecording ? Colors.red : Colors.white,
                  boxShadow: [
                    BoxShadow(
                      color: (_isRecording ? Colors.red : Colors.white)
                          .withOpacity(0.4),
                      blurRadius: 20,
                      spreadRadius: 5,
                    ),
                  ],
                ),
                child: Center(
                  child: _isProcessing
                      ? const CircularProgressIndicator(color: Color(0xFF1B5E20))
                      : Icon(
                          _isRecording ? Icons.stop : Icons.mic,
                          size: 50,
                          color: const Color(0xFF1B5E20),
                        ),
                ),
              ),
            ),
            
            const Spacer(),
            
            // 提示文字
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 40),
              child: Text(
                _isRecording
                    ? '请将手机靠近鸟类叫声来源\n保持安静环境效果最佳'
                    : '录音约5-10秒即可识别\n支持大部分常见鸟类',
                textAlign: TextAlign.center,
                style: const TextStyle(color: Colors.white60, fontSize: 14),
              ),
            ),
            
            const SizedBox(height: 60),
          ],
        ),
      ),
    );
  }

  String _formatDuration(Duration d) {
    final minutes = d.inMinutes.toString().padLeft(2, '0');
    final seconds = (d.inSeconds % 60).toString().padLeft(2, '0');
    return '$minutes:$seconds';
  }
}

第三步:后端服务(Supabase)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
// lib/core/services/supabase_service.dart
import 'package:supabase_flutter/supabase_flutter.dart';

class SupabaseService {
  static final client = Supabase.instance.client;
  
  /// 用户注册/登录(匿名)
  static Future<String> ensureAnonymousUser() async {
    final session = client.auth.currentSession;
    if (session != null) return session.user.id;
    
    final response = await client.auth.signInAnonymously();
    return response.user!.id;
  }
  
  /// 上传观察照片
  static Future<String> uploadObservationPhoto(
    String filePath, 
    String userId,
  ) async {
    final fileName = '$userId/${DateTime.now().millisecondsSinceEpoch}.jpg';
    
    await client.storage
        .from('observations')
        .upload(fileName, Uri.file(filePath));
    
    return client.storage
        .from('observations')
        .getPublicUrl(fileName);
  }
  
  /// 保存观察记录
  static Future<void> saveObservation({
    required String speciesId,
    required double latitude,
    required double longitude,
    required String imageUrl,
    String? notes,
    required int confidence,
  }) async {
    final userId = await ensureAnonymousUser();
    
    await client.from('observations').insert({
      'user_id': userId,
      'species_id': speciesId,
      'latitude': latitude,
      'longitude': longitude,
      'image_url': imageUrl,
      'notes': notes,
      'confidence': confidence,
      'observed_at': DateTime.now().toIso8601String(),
    });
  }
  
  /// 获取社区热门鸟类
  static Future<List<Map<String, dynamic>>> getPopularBirds() async {
    final response = await client
        .from('observations')
        .select('species_id, bird_species(name_cn, name_en, image_url)')
        .order('observed_at', ascending: false)
        .limit(100);
    
    // 统计每个物种的观察次数
    final Map<String, Map<String, dynamic>> stats = {};
    for (final obs in response) {
      final speciesId = obs['species_id'] as String;
      final species = obs['bird_species'];
      if (species != null) {
        stats[speciesId] = {
          ...Map.from(species),
          'count': (stats[speciesId]?['count'] ?? 0) + 1,
        };
      }
    }
    
    final sorted = stats.entries.toList()
      ..sort((a, b) => (b.value['count'] as int).compareTo(
        a.value['count'] as int));
    
    return sorted.map((e) => e.value).take(10).toList();
  }
}

第四步:UI设计要点

自然主题配色

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// lib/core/theme/app_theme.dart
class AppTheme {
  static const Color primaryGreen = Color(0xFF2E7D32);
  static const Color lightGreen = Color(0xFF81C784);
  static const Color forestGreen = Color(0xFF1B5E20);
  static const Color skyBlue = Color(0xFF42A5F5);
  static const Color earthBrown = Color(0xFF795548);
  static const Color warmWhite = Color(0xFFFFF8E1);
  
  static ThemeData get theme => ThemeData(
    primaryColor: primaryGreen,
    colorScheme: ColorScheme.fromSeed(
      seedColor: primaryGreen,
      brightness: Brightness.light,
    ),
    scaffoldBackgroundColor: warmWhite,
    appBarTheme: const AppBarTheme(
      backgroundColor: primaryGreen,
      foregroundColor: Colors.white,
      elevation: 0,
    ),
    cardTheme: CardTheme(
      elevation: 2,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(12),
      ),
    ),
    floatingActionButtonTheme: const FloatingActionButtonThemeData(
      backgroundColor: primaryGreen,
      foregroundColor: Colors.white,
    ),
  );
}

鸟类卡片组件

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
// lib/features/field_guide/widgets/bird_card.dart
class BirdCard extends StatelessWidget {
  final BirdSpeciesData species;
  final VoidCallback onTap;
  
  const BirdCard({super.key, required this.species, required this.onTap});
  
  @override
  Widget build(BuildContext context) {
    return Card(
      margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
      child: InkWell(
        onTap: onTap,
        borderRadius: BorderRadius.circular(12),
        child: Padding(
          padding: const EdgeInsets.all(12),
          child: Row(
            children: [
              // 鸟类图片
              ClipRRect(
                borderRadius: BorderRadius.circular(8),
                child: Image.network(
                  species.imageUrl,
                  width: 80,
                  height: 80,
                  fit: BoxFit.cover,
                  errorBuilder: (_, __, ___) => Container(
                    width: 80,
                    height: 80,
                    color: Colors.grey[200],
                    child: const Icon(Icons.birding, size: 40, color: Colors.grey),
                  ),
                ),
              ),
              const SizedBox(width: 12),
              
              // 信息区
              Expanded(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(
                      species.nameCn,
                      style: const TextStyle(
                        fontSize: 18,
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                    const SizedBox(height: 2),
                    Text(
                      species.nameEn,
                      style: TextStyle(fontSize: 13, color: Colors.grey[600]),
                    ),
                    const SizedBox(height: 4),
                    Row(
                      children: [
                        _Tag(
                          label: species.family,
                          color: AppTheme.lightGreen,
                        ),
                        const SizedBox(width: 6),
                        _Tag(
                          label: species.conservationStatus,
                          color: _getStatusColor(species.conservationStatus),
                        ),
                      ],
                    ),
                  ],
                ),
              ),
              
              const Icon(Icons.chevron_right, color: Colors.grey),
            ],
          ),
        ),
      ),
    );
  }
  
  Color _getStatusColor(String status) {
    if (status.contains('危')) return Colors.red[100]!;
    if (status.contains('易危')) return Colors.orange[100]!;
    return Colors.green[100]!;
  }
}

class _Tag extends StatelessWidget {
  final String label;
  final Color color;
  
  const _Tag({required this.label, required this.color});
  
  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
      decoration: BoxDecoration(
        color: color,
        borderRadius: BorderRadius.circular(12),
      ),
      child: Text(
        label,
        style: const TextStyle(fontSize: 11),
      ),
    );
  }
}

踩坑记录

1. BirdNET模型体积太大

BirdNET完整模型约200MB,直接打包进App不行。解决方案:

  • 首次启动时从云端下载模型到本地
  • 用ProgressDialog显示下载进度
  • 支持断点续传

2. 录音权限问题

iOS和Android对麦克风权限要求严格。Android需要RECORD_AUDIO权限,iOS需要在Info.plist中添加NSS microphoneUsageDescription。记得处理用户拒绝权限的情况。

3. 离线数据库同步

鸟类知识库需要定期更新(新发现的物种、保护级别变化)。方案:

  • 本地SQLite存完整数据
  • 启动时检查更新,增量同步
  • 只有WiFi下自动更新,移动数据下提示用户

4. 电量消耗

相机和AI推理都很耗电。优化策略:

  • 相机预览降低帧率(15fps足够)
  • AI推理用GPU加速(TFLite GPU Delegate)
  • 后台不保留相机实例

变现思路

模式 具体方案 预估收入
免费+广告 底部Banner广告 ¥0.5-2/用户/月
Pro版 解锁高级功能(声音识别、离线包、无广告) ¥12-30/月
观鸟装备 望远镜、图鉴书等电商 佣金5-15%
课程 观鸟入门课程、鸟类摄影课 ¥99-299/课
会员社群 VIP观鸟社群、线下活动 ¥199/年

建议起步方案:免费版保留基础识别功能,Pro版解锁声音识别+完整图鉴+无广告,定价¥18/月或¥128/年。

总结

打造一个"懂鸟"App的关键技术挑战:

  1. 离线优先:观鸟场景网络不稳定,核心功能必须离线可用
  2. 多模态输入:图像+声音双通道识别,覆盖更多场景
  3. 个性化体验:记住用户观察历史,推荐感兴趣的鸟类
  4. 社区驱动:让用户贡献数据(声音样本、分布记录),形成飞轮

技术栈总结:Flutter(跨平台)+ BirdNET(本地AI)+ Supabase(后端)+ SQLite(本地存储)。这套架构不仅能做鸟类识别,换掉模型和数据,就能做任何物种识别App——植物、昆虫、石头、星星。