https://www.gravatar.com/avatar/7a0c24f697ea1587001c36d00039b60f?s=240&d=mp

MySQL慢查询优化实战:从EXPLAIN分析到索引重构,让查询快100倍

为什么你的查询越来越慢?

在实际项目中,随着数据量增长,原本运行良好的 SQL 查询可能在某天突然变慢。一个没有优化的慢查询不仅拖慢整个系统,还可能在高并发下导致数据库连接池耗尽,引发雪崩效应。

打造鸟类识别智能体:用多模态AI让手机变成观鸟助手

为什么要做鸟类识别Agent?

观鸟是全球最受欢迎的自然爱好之一,中国观鸟人群已超过100万。但新手面临的最大痛点是:看到一只鸟,拍了照片,不知道是什么鸟。

市面上已有 Merlin Bird ID、懂鸟等App,但它们只是"工具"——你拍照,它告诉你名字。我们想要的是一个智能体

用CrewAI构建多智能体协作系统:让多个Agent协同完成复杂任务

为什么需要多智能体?

一个Agent能回答问题、调用工具,但面对复杂任务时力不从心:

  • 写一篇深度技术文章:需要调研、写大纲、写正文、审校
  • 做竞品分析:需要搜集数据、分析对比、生成报告
  • 开发一个小项目:需要设计架构、写代码、测试

这些任务拆分给多个专业Agent协作,效果远好于让一个Agent包揽一切。这就是多智能体系统(Multi-Agent System)的核心思想。

用Kotlin+Jetpack Compose打造懂鸟App:Android原生观鸟助手完整开发指南

为什么选Android原生?

Android在鸟类识别App上有独特优势:

优势 具体体现
GPU推理 NNAPI + GPU Delegate,TFLite推理速度碾压跨平台
生态成熟 BirdNET官方提供Android SDK,开箱即用
权限灵活 12+可后台录音,观鸟场景更实用
设备覆盖 华为、小米、OPPO全兼容,国内观鸟用户主力
包体积 AAB分发,首包可控在25MB以内

技术架构

 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
┌─────────────────────────────────────────────────────────┐
│                  懂鸟 Android App                       │
│                (Kotlin + Compose)                        │
├─────────────────────────────────────────────────────────┤
│                                                         │
│  ┌───────────┐  ┌───────────┐  ┌─────────────────────┐│
│  │ 拍照识鸟   │  │ 声音识别   │  │     鸟类图鉴        ││
│  │(CameraX)  │  │(AudioRec) │  │     (Room)         ││
│  └─────┬─────┘  └─────┬─────┘  └──────────┬──────────┘│
│        │              │                   │            │
│  ┌─────▼──────────────▼───────────────────▼──────────┐│
│  │              本地推理引擎                            ││
│  │  ┌────────┐  ┌───────────┐  ┌──────────────────┐ ││
│  │  │ TFLite │  │ BirdNET   │  │  Room + FTS5     │ ││
│  │  │ (图像) │  │ (声音)    │  │  (知识库)        │ ││
│  │  └────────┘  └───────────┘  └──────────────────┘ ││
│  └──────────────────────────────────────────────────┘│
│                          │                             │
│  ┌───────────────────────▼──────────────────────────┐│
│  │              云端服务                              ││
│  │  ┌────────┐  ┌────────┐  ┌────────────────────┐ ││
│  │  │GPT-5.4  │  │Firebase│  │    Google Play      │ ││
│  │  └────────┘  └────────┘  └────────────────────┘ ││
│  └──────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────┘

项目初始化

 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
// build.gradle.kts (Project)
plugins {
    id("com.android.application") version "9.2.1" apply false
    id("org.jetbrains.kotlin.android") version "2.3.21" apply false
    id("org.jetbrains.kotlin.plugin.compose") version "2.3.21" apply false
    id("com.google.devtools.ksp") version "2.3.9" apply false
}

// build.gradle.kts (app)
plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
    id("org.jetbrains.kotlin.plugin.compose")
    id("com.google.devtools.ksp")
}

android {
    namespace = "com.dongniao.app"
    compileSdk = 36
    
    defaultConfig {
        applicationId = "com.dongniao.app"
        minSdk = 26
        targetSdk = 36
        versionCode = 1
        versionName = "1.0.0"
        
        ndk {
            abiFilters += setOf("armeabi-v7a", "arm64-v8a", "x86_64")
        }
    }
    
    buildFeatures {
        compose = true
    }
}

dependencies {
    // Compose BOM
    implementation(platform("androidx.compose:compose-bom:2026.05.01"))
    implementation("androidx.compose.material3:material3")
    implementation("androidx.compose.ui:ui")
    implementation("androidx.compose.ui:ui-tooling-preview")
    implementation("androidx.activity:activity-compose:1.13.0")
    implementation("androidx.lifecycle:lifecycle-runtime-compose:2.10.0")
    implementation("androidx.navigation:navigation-compose:2.9.8")
    
    // CameraX
    implementation("androidx.camera:camera-core:1.3.4")
    implementation("androidx.camera:camera-camera2:1.3.4")
    implementation("androidx.camera:camera-lifecycle:1.3.4")
    implementation("androidx.camera:camera-view:1.3.4")
    
    // TFLite
    implementation("org.tensorflow:tensorflow-lite:2.16.1")
    implementation("org.tensorflow:tensorflow-lite-support:0.4.4")
    implementation("org.tensorflow:tensorflow-lite-gpu:2.16.1")
    implementation("org.tensorflow:tensorflow-lite-task-vision:0.4.4")
    
    // Room
    implementation("androidx.room:room-runtime:2.8.4")
    implementation("androidx.room:room-ktx:2.8.4")
    ksp("androidx.room:room-compiler:2.8.4")
    
    // 其他
    implementation("io.coil-kt:coil-compose:2.7.0")
    implementation("com.google.accompanist:accompanist-permissions:0.34.0")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
}

项目结构

 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
DongNiao/
├── app/src/main/java/com/dongniao/app/
│   ├── DongNiaoApplication.kt              # Application入口
│   ├── MainActivity.kt                     # 主Activity
│   ├── ui/
│   │   ├── theme/
│   │   │   ├── Color.kt                    # 颜色定义
│   │   │   ├── Theme.kt                    # Material 3主题
│   │   │   └── Type.kt                     # 字体定义
│   │   ├── navigation/
│   │   │   └── NavGraph.kt                 # 导航图
│   │   └── screens/
│   │       ├── camera/
│   │       │   ├── CameraScreen.kt         # 拍照界面
│   │       │   └── CameraViewModel.kt
│   │       ├── sound/
│   │       │   ├── SoundScreen.kt          # 声音识别
│   │       │   └── SoundViewModel.kt
│   │       ├── guide/
│   │       │   ├── GuideScreen.kt          # 图鉴列表
│   │       │   ├── SpeciesDetailScreen.kt  # 物种详情
│   │       │   └── GuideViewModel.kt
│   │       ├── journal/
│   │       │   ├── JournalScreen.kt        # 观察日记
│   │       │   └── JournalViewModel.kt
│   │       └── chat/
│   │           ├── ChatScreen.kt           # AI对话
│   │           └── ChatViewModel.kt
│   ├── data/
│   │   ├── database/
│   │   │   ├── BirdDatabase.kt             # Room数据库
│   │   │   ├── BirdSpeciesDao.kt           # 物种DAO
│   │   │   ├── ObservationDao.kt           # 观察DAO
│   │   │   └── Converters.kt               # 类型转换器
│   │   ├── model/
│   │   │   ├── BirdSpecies.kt              # 物种实体
│   │   │   └── Observation.kt              # 观察实体
│   │   └── repository/
│   │       ├── BirdRepository.kt           # 物种仓库
│   │       └── ObservationRepository.kt    # 观察仓库
│   └── services/
│       ├── BirdNETService.kt               # BirdNET推理
│       ├── VisionService.kt                # 图像识别
│       ├── AudioRecorder.kt                # 音频录制
│       └── ChatService.kt                  # AI对话
├── app/src/main/res/
│   ├── raw/
│   │   ├── birdnet.tflite                  # 图像分类模型
│   │   └── birdnet_sound.tflite            # 声音分类模型
│   └── values/
│       ├── strings.xml
│       └── themes.xml
└── app/src/main/AndroidManifest.xml

第一步:数据模型

 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
// data/model/BirdSpecies.kt
package com.dongniao.app.data.model

import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "bird_species")
data class BirdSpecies(
    @PrimaryKey(autoGenerate = true)
    val id: Long = 0,
    val nameCn: String,          // 中文名
    val nameEn: String,          // 英文名
    val scientificName: String,  // 学名
    val family: String,          // 科
    val orderName: String,       // 目
    val habitat: String,         // 栖息地
    val distribution: String,    // 分布
    val diet: String,            // 食性
    val conservationStatus: String, // 保护级别
    val descriptionText: String, // 描述
    val voiceDescription: String, // 叫声描述
    val nesting: String,         // 筑巢习性
    val migration: String,       // 迁徙习性
    val isFavorite: Boolean = false,
    val viewCount: Int = 0
)

// data/model/Observation.kt
@Entity(
    tableName = "observations",
    foreignKeys = [
        ForeignKey(
            entity = BirdSpecies::class,
            parentColumns = ["id"],
            childColumns = ["speciesId"],
            onDelete = ForeignKey.SET_NULL
        )
    ]
)
data class Observation(
    @PrimaryKey(autoGenerate = true)
    val id: Long = 0,
    val speciesId: Long?,
    val imagePath: String,
    val latitude: Double,
    val longitude: Double,
    val notes: String? = null,
    val confidence: Int,
    val observedAt: Long,
    val createdAt: Long = System.currentTimeMillis()
)

// data/model/ObservationWithBird.kt
data class ObservationWithBird(
    val observation: Observation,
    val species: BirdSpecies?
)

第二步:Room数据库

  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
// data/database/BirdDatabase.kt
package com.dongniao.app.data.database

import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import com.dongniao.app.data.model.BirdSpecies
import com.dongniao.app.data.model.Observation

@Database(
    entities = [BirdSpecies::class, Observation::class],
    version = 1,
    exportSchema = false
)
abstract class BirdDatabase : RoomDatabase() {
    abstract fun birdSpeciesDao(): BirdSpeciesDao
    abstract fun observationDao(): ObservationDao
    
    companion object {
        @Volatile
        private var INSTANCE: BirdDatabase? = null
        
        fun getDatabase(context: Context): BirdDatabase {
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    BirdDatabase::class.java,
                    "dongniao_database"
                ).build()
                INSTANCE = instance
                instance
            }
        }
    }
}

// data/database/BirdSpeciesDao.kt
@Dao
interface BirdSpeciesDao {
    @Query("SELECT * FROM bird_species ORDER BY nameCn ASC")
    fun getAllBirds(): Flow<List<BirdSpecies>>
    
    @Query("SELECT * FROM bird_species WHERE family = :family ORDER BY nameCn ASC")
    fun getBirdsByFamily(family: String): Flow<List<BirdSpecies>>
    
    @Query("""
        SELECT * FROM bird_species 
        WHERE nameCn LIKE '%' || :query || '%' 
        OR nameEn LIKE '%' || :query || '%'
    """)
    fun searchBirds(query: String): Flow<List<BirdSpecies>>
    
    @Query("SELECT DISTINCT family FROM bird_species ORDER BY family ASC")
    fun getAllFamilies(): Flow<List<String>>
    
    @Query("SELECT * FROM bird_species WHERE id = :id")
    suspend fun getBirdById(id: Long): BirdSpecies?
    
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertBird(bird: BirdSpecies): Long
    
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertAll(birds: List<BirdSpecies>)
    
    @Update
    suspend fun updateBird(bird: BirdSpecies)
    
    @Query("UPDATE bird_species SET isFavorite = :isFavorite WHERE id = :id")
    suspend fun toggleFavorite(id: Long, isFavorite: Boolean)
}

// data/database/ObservationDao.kt
@Dao
interface ObservationDao {
    @Transaction
    @Query("SELECT * FROM observations ORDER BY observedAt DESC")
    fun getAllObservations(): Flow<List<ObservationWithBird>>
    
    @Transaction
    @Query("SELECT * FROM observations WHERE speciesId = :speciesId ORDER BY observedAt DESC")
    fun getObservationsBySpecies(speciesId: Long): Flow<List<ObservationWithBird>>
    
    @Query("SELECT COUNT(*) FROM observations")
    fun getTotalObservations(): Flow<Int>
    
    @Query("SELECT COUNT(DISTINCT speciesId) FROM observations")
    fun getTotalSpecies(): Flow<Int>
    
    @Query("SELECT MIN(observedAt) FROM observations")
    suspend fun getFirstObservationTime(): Long?
    
    @Insert
    suspend fun insertObservation(observation: Observation): Long
    
    @Delete
    suspend fun deleteObservation(observation: Observation)
    
    @Query("DELETE FROM observations WHERE id = :id")
    suspend fun deleteObservationById(id: Long)
}

第三步:主题和颜色

 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
// ui/theme/Color.kt
package com.dongniao.app.ui.theme

import androidx.compose.ui.graphics.Color

// 自然绿色系
val PrimaryGreen = Color(0xFF2E7D32)
val PrimaryGreenLight = Color(0xFF81C784)
val PrimaryGreenDark = Color(0xFF1B5E20)
val ForestGreen = Color(0xFF1B5E20)
val LeafGreen = Color(0xFF4CAF50)
val SkyBlue = Color(0xFF42A5F5)
val EarthBrown = Color(0xFF795548)
val WarmWhite = Color(0xFFFFF8E1)
val BirdYellow = Color(0xFFFFC107)

// 动态颜色(Android 12+)
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme

private val LightColorScheme = lightColorScheme(
    primary = PrimaryGreen,
    onPrimary = Color.White,
    primaryContainer = PrimaryGreenLight,
    onPrimaryContainer = PrimaryGreenDark,
    secondary = SkyBlue,
    background = WarmWhite,
    surface = Color.White,
)

private val DarkColorScheme = darkColorScheme(
    primary = PrimaryGreenLight,
    onPrimary = PrimaryGreenDark,
    primaryContainer = PrimaryGreen,
    onPrimaryContainer = PrimaryGreenLight,
    secondary = SkyBlue,
    background = Color(0xFF121212),
    surface = Color(0xFF1E1E1E),
)

// ui/theme/Theme.kt
@Composable
fun DongNiaoTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    dynamicColor: Boolean = true,
    content: @Composable () -> Unit
) {
    val colorScheme = when {
        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
            val context = LocalContext.current
            if (darkTheme) dynamicDarkColorScheme(context) 
            else dynamicLightColorScheme(context)
        }
        darkTheme -> DarkColorScheme
        else -> LightColorScheme
    }
    
    MaterialTheme(
        colorScheme = colorScheme,
        typography = Typography,
        content = content
    )
}

第四步:App入口和导航

  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
// DongNiaoApplication.kt
package com.dongniao.app

import android.app.Application
import com.dongniao.app.data.database.BirdDatabase

class DongNiaoApplication : Application() {
    val database by lazy { BirdDatabase.getDatabase(this) }
}

// MainActivity.kt
package com.dongniao.app

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import com.dongniao.app.ui.navigation.DongNiaoNavGraph
import com.dongniao.app.ui.theme.DongNiaoTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            DongNiaoTheme {
                DongNiaoNavGraph()
            }
        }
    }
}

// ui/navigation/NavGraph.kt
sealed class Screen(val route: String) {
    object Camera : Screen("camera")
    object Sound : Screen("sound")
    object Guide : Screen("guide")
    object SpeciesDetail : Screen("guide/{speciesId}") {
        fun createRoute(speciesId: Long) = "guide/$speciesId"
    }
    object Journal : Screen("journal")
    object Chat : Screen("chat")
}

@Composable
fun DongNiaoNavGraph() {
    val navController = rememberNavController()
    
    Scaffold(
        bottomBar = {
            NavigationBar {
                val navBackStackEntry by navController.currentBackStackEntryAsState()
                val currentRoute = navBackStackEntry?.destination?.route
                
                items.forEach { item ->
                    NavigationBarItem(
                        icon = { Icon(item.icon, contentDescription = item.title) },
                        label = { Text(item.title) },
                        selected = currentRoute == item.route,
                        onClick = {
                            navController.navigate(item.route) {
                                popUpTo(navController.graph.findStartDestination().id) {
                                    saveState = true
                                }
                                launchSingleTop = true
                                restoreState = true
                            }
                        }
                    )
                }
            }
        }
    ) { innerPadding ->
        NavHost(
            navController = navController,
            startDestination = Screen.Camera.route,
            modifier = Modifier.padding(innerPadding)
        ) {
            composable(Screen.Camera.route) {
                CameraScreen(
                    onNavigateToResult = { result ->
                        navController.navigate("result/${result.speciesCn}/${result.confidence}")
                    }
                )
            }
            composable(Screen.Sound.route) {
                SoundScreen()
            }
            composable(Screen.Guide.route) {
                GuideScreen(
                    onSpeciesClick = { speciesId ->
                        navController.navigate(Screen.SpeciesDetail.createRoute(speciesId))
                    }
                )
            }
            composable(
                Screen.SpeciesDetail.route,
                arguments = listOf(navArgument("speciesId") { type = NavType.LongType })
            ) { backStackEntry ->
                val speciesId = backStackEntry.arguments?.getLong("speciesId") ?: return@composable
                SpeciesDetailScreen(speciesId = speciesId)
            }
            composable(Screen.Journal.route) {
                JournalScreen()
            }
            composable(Screen.Chat.route) {
                ChatScreen()
            }
        }
    }
}

data class BottomNavItem(
    val title: String,
    val icon: ImageVector,
    val route: String
)

val items = listOf(
    BottomNavItem("识鸟", Icons.Default.CameraAlt, Screen.Camera.route),
    BottomNavItem("听声", Icons.Default.Mic, Screen.Sound.route),
    BottomNavItem("图鉴", Icons.Default.MenuBook, Screen.Guide.route),
    BottomNavItem("日记", Icons.Default.Note, Screen.Journal.route),
    BottomNavItem("助手", Icons.Default.Chat, Screen.Chat.route),
)

第五步:拍照识鸟(CameraX)

  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
// ui/screens/camera/CameraScreen.kt
package com.dongniao.app.ui.screens.camera

import android.Manifest
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.*
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import java.io.File
import java.text.SimpleDateFormat
import java.util.*

@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
@Composable
fun CameraScreen(
    onNavigateToResult: (BirdIdentification) -> Unit
) {
    val context = LocalContext.current
    val lifecycleOwner = LocalLifecycleOwner.current
    
    // 相机权限
    val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA)
    
    // 状态
    var isProcessing by remember { mutableStateOf(false) }
    var hasFlash by remember { mutableStateOf(false) }
    
    // ImageCapture用例
    val imageCapture = remember { ImageCapture.Builder()
        .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
        .build()
    }
    
    // 相机预览
    val previewView = remember { PreviewView(context) }
    
    LaunchedEffect(Unit) {
        if (!cameraPermissionState.status.isGranted) {
            cameraPermissionState.launchPermissionRequest()
        }
    }
    
    LaunchedEffect(cameraPermissionState.status.isGranted) {
        if (cameraPermissionState.status.isGranted) {
            val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
            cameraProviderFuture.addListener({
                val cameraProvider = cameraProviderFuture.get()
                
                val preview = Preview.Builder().build().also {
                    it.surfaceProvider = previewView.surfaceProvider
                }
                
                try {
                    cameraProvider.unbindAll()
                    cameraProvider.bindToLifecycle(
                        lifecycleOwner,
                        CameraSelector.DEFAULT_BACK_CAMERA,
                        preview,
                        imageCapture
                    )
                } catch (e: Exception) {
                    e.printStackTrace()
                }
            }, ContextCompat.getMainExecutor(context))
        }
    }
    
    Box(modifier = Modifier.fillMaxSize()) {
        // 相机预览
        AndroidView(
            factory = { previewView },
            modifier = Modifier.fillMaxSize()
        )
        
        // 顶部提示
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp)
                .align(Alignment.TopCenter)
        ) {
            Surface(
                color = Color.Black.copy(alpha = 0.6f),
                shape = RoundedCornerShape(20.dp)
            ) {
                Text(
                    text = "📸 对准鸟类拍照",
                    color = Color.White,
                    modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
                )
            }
        }
        
        // 底部操作栏
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .align(Alignment.BottomCenter)
                .padding(bottom = 32.dp),
            horizontalArrangement = Arrangement.SpaceEvenly,
            verticalAlignment = Alignment.CenterVertically
        ) {
            // 闪光灯
            IconButton(
                onClick = { hasFlash = !hasFlash },
                modifier = Modifier.size(48.dp)
            ) {
                Icon(
                    imageVector = if (hasFlash) Icons.Default.FlashOn 
                                 else Icons.Default.FlashOff,
                    contentDescription = "闪光灯",
                    tint = Color.White
                )
            }
            
            // 拍照按钮
            FloatingActionButton(
                onClick = {
                    if (!isProcessing) {
                        isProcessing = true
                        takePhoto(
                            context = context,
                            imageCapture = imageCapture,
                            onImageCaptured = { uri ->
                                // 识别图片
                                identifyImage(context, uri) { result ->
                                    isProcessing = false
                                    result?.let { onNavigateToResult(it) }
                                }
                            }
                        )
                    }
                },
                shape = CircleShape,
                modifier = Modifier.size(80.dp),
                containerColor = if (isProcessing) Color.Gray else Color.White
            ) {
                if (isProcessing) {
                    CircularProgressIndicator(
                        modifier = Modifier.size(40.dp),
                        color = Color.Green
                    )
                } else {
                    Icon(
                        imageVector = Icons.Default.CameraAlt,
                        contentDescription = "拍照",
                        tint = Color(0xFF2E7D32),
                        modifier = Modifier.size(40.dp)
                    )
                }
            }
            
            // 声音识别
            IconButton(
                onClick = { /* 导航到声音识别 */ },
                modifier = Modifier.size(48.dp)
            ) {
                Icon(
                    imageVector = Icons.Default.Mic,
                    contentDescription = "声音识别",
                    tint = Color.White
                )
            }
        }
    }
}

private fun takePhoto(
    context: Context,
    imageCapture: ImageCapture,
    onImageCaptured: (Uri) -> Unit
) {
    val photoFile = File(
        context.filesDir,
        "bird_${SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())}.jpg"
    )
    
    val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build()
    
    imageCapture.takePicture(
        outputOptions,
        ContextCompat.getMainExecutor(context),
        object : ImageCapture.OnImageSavedCallback {
            override fun onImageSaved(output: ImageCapture.OutputFileResults) {
                onImageCaptured(Uri.fromFile(photoFile))
            }
            
            override fun onError(exception: ImageCaptureException) {
                exception.printStackTrace()
            }
        }
    )
}

第六步:TFLite推理服务

  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
// services/VisionService.kt
package com.dongniao.app.services

import android.content.Context
import android.graphics.Bitmap
import android.graphics.ImageDecoder
import android.net.Uri
import org.tensorflow.lite.Interpreter
import org.tensorflow.lite.gpu.GpuDelegate
import java.io.FileInputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.MappedByteBuffer
import java.nio.channels.FileChannel

data class BirdIdentification(
    val speciesCn: String,
    val speciesEn: String,
    val scientificName: String,
    val confidence: Double,
    val allCandidates: List<Pair<String, Double>>
)

class VisionService(private val context: Context) {
    
    private var interpreter: Interpreter? = null
    private var labels: List<String> = emptyList()
    private var gpuDelegate: GpuDelegate? = null
    
    init {
        loadModel()
    }
    
    private fun loadModel() {
        // 加载标签
        labels = context.assets.open("labels.txt").bufferedReader().readLines()
        
        // 配置GPU加速
        val options = Interpreter.Options().apply {
            gpuDelegate = GpuDelegate().also { addDelegate(it) }
            setNumThreads(4)
        }
        
        // 加载模型
        val modelBuffer = loadModelFile("birdnet.tflite")
        interpreter = Interpreter(modelBuffer, options)
    }
    
    private fun loadModelFile(modelName: String): MappedByteBuffer {
        val fileDescriptor = context.assets.openFd(modelName)
        val inputStream = FileInputStream(fileDescriptor.fileDescriptor)
        val fileChannel = inputStream.channel
        return fileChannel.map(
            FileChannel.MapMode.READ_ONLY,
            fileDescriptor.startOffset,
            fileDescriptor.declaredLength
        )
    }
    
    fun identify(fromUri: Uri): BirdIdentification? {
        val bitmap = ImageDecoder.decodeBitmap(
            ImageDecoder.createSource(context.contentResolver, fromUri)
        ).copy(Bitmap.Config.ARGB_8888, false)
        
        return identifyFromBitmap(bitmap)
    }
    
    private fun identifyFromBitmap(bitmap: Bitmap): BirdIdentification? {
        val interpreter = this.interpreter ?: return null
        
        // 预处理图片:224x224,归一化到[0,1]
        val resizedBitmap = Bitmap.createScaledBitmap(bitmap, 224, 224, true)
        val inputBuffer = bitmapToByteBuffer(resizedBitmap)
        
        // 推理
        val output = Array(1) { FloatArray(labels.size) }
        interpreter.run(inputBuffer, output)
        
        // 解析结果
        val scores = output[0]
        val indexedScores = scores.mapIndexed { index, score -> 
            Pair(index, score) 
        }.sortedByDescending { it.second }
        
        val topResults = indexedScores.take(5).map { (index, score) ->
            val englishName = labels[index]
            Pair(BirdSpeciesMapper.mapToChinese(englishName), score.toDouble())
        }
        
        val top = topResults.firstOrNull() ?: return null
        
        return BirdIdentification(
            speciesCn = top.first,
            speciesEn = labels[indexedScores.first().first],
            scientificName = BirdSpeciesMapper.mapToScientific(labels[indexedScores.first().first]),
            confidence = top.second,
            allCandidates = topResults
        )
    }
    
    private fun bitmapToByteBuffer(bitmap: Bitmap): ByteBuffer {
        val buffer = ByteBuffer.allocateDirect(1 * 224 * 224 * 3 * 4)
        buffer.order(ByteOrder.nativeOrder())
        
        val pixels = IntArray(224 * 224)
        bitmap.getPixels(pixels, 0, 224, 0, 0, 224, 224)
        
        for (pixel in pixels) {
            // 归一化到[-1, 1]
            buffer.putFloat(((pixel shr 16) and 0xFF) / 127.5f - 1.0f)  // R
            buffer.putFloat(((pixel shr 8) and 0xFF) / 127.5f - 1.0f)   // G
            buffer.putFloat((pixel and 0xFF) / 127.5f - 1.0f)           // B
        }
        
        return buffer
    }
    
    fun close() {
        interpreter?.close()
        gpuDelegate?.close()
    }
}

// 鸟类名称映射
object BirdSpeciesMapper {
    private val mapping = mapOf(
        "Eurasian Tree Sparrow" to Pair("麻雀", "Passer montanus"),
        "Little Egret" to Pair("白鹭", "Egretta garzetta"),
        "Common Kingfisher" to Pair("翠鸟", "Alcedo atthis"),
        "Eurasian Magpie" to Pair("喜鹊", "Pica pica"),
        "Red-billed Blue Magpie" to Pair("红嘴蓝鹊", "Urocissa erythroryncha"),
        "Light-vented Bulbul" to Pair("白头鹎", "Pycnonotus sinensis"),
        "Spotted Dove" to Pair("珠颈斑鸠", "Spilopelia chinensis"),
        "Oriental Magpie-Robin" to Pair("鹊鸲", "Copsychus saularis"),
        "Chinese Hwamei" to Pair("画眉", "Garrulax canorus"),
        "Crested Myna" to Pair("八哥", "Acridotheres cristatellus"),
    )
    
    fun mapToChinese(englishName: String): String {
        return mapping[englishName]?.first ?: englishName
    }
    
    fun mapToScientific(englishName: String): String {
        return mapping[englishName]?.second ?: ""
    }
}

第七步:声音识别

  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
// services/AudioRecorder.kt
package com.dongniao.app.services

import android.Manifest
import android.content.Context
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import android.os.Build
import android.util.Log
import org.tensorflow.lite.Interpreter
import org.tensorflow.lite.gpu.GpuDelegate
import java.io.File
import java.io.FileOutputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder

class AudioRecorder(private val context: Context) {
    
    companion object {
        private const val TAG = "AudioRecorder"
        private const val SAMPLE_RATE = 48000
        private const val CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO
        private const val AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT
        private const val CHUNK_SIZE = 3 * 48000 // 3秒 chunks for BirdNET
    }
    
    private var audioRecord: AudioRecord? = null
    private var interpreter: Interpreter? = null
    private var isRecording = false
    private var recordingThread: Thread? = null
    
    // BirdNET检测到的鸟类
    var onBirdDetected: ((List<Pair<String, Double>>) -> Unit)? = null
    
    fun startRecording() {
        if (isRecording) return
        
        val bufferSize = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT)
        
        audioRecord = AudioRecord(
            MediaRecorder.AudioSource.MIC,
            SAMPLE_RATE,
            CHANNEL_CONFIG,
            AUDIO_FORMAT,
            bufferSize * 2
        )
        
        // 加载BirdNET声音模型
        loadSoundModel()
        
        isRecording = true
        audioRecord?.startRecording()
        
        recordingThread = Thread {
            val buffer = ByteArray(CHUNK_SIZE * 2) // 16-bit = 2 bytes per sample
            val audioBuffer = ByteBuffer.allocateDirect(CHUNK_SIZE * 4)
            audioBuffer.order(ByteOrder.nativeOrder())
            
            while (isRecording) {
                val read = audioRecord?.read(buffer, 0, buffer.size) ?: 0
                if (read > 0) {
                    // 转换为float
                    audioBuffer.clear()
                    for (i in 0 until read / 2) {
                        val sample = (buffer[i * 2].toInt() and 0xFF) or 
                                     (buffer[i * 2 + 1].toInt() shl 8)
                        audioBuffer.putFloat(sample.toFloat() / 32768.0f)
                    }
                    
                    // 推理
                    if (audioBuffer.position() >= CHUNK_SIZE) {
                        audioBuffer.flip()
                        val result = classifyAudio(audioBuffer)
                        if (result.isNotEmpty()) {
                            onBirdDetected?.invoke(result)
                        }
                        audioBuffer.clear()
                    }
                }
            }
        }.also { it.start() }
    }
    
    fun stopRecording() {
        isRecording = false
        recordingThread?.join()
        audioRecord?.stop()
        audioRecord?.release()
        audioRecord = null
        interpreter?.close()
        interpreter = null
    }
    
    private fun loadSoundModel() {
        try {
            val options = Interpreter.Options().apply {
                setNumThreads(4)
            }
            val modelBuffer = context.assets.openFd("birdnet_sound.tflite").let {
                val inputStream = java.io.FileInputStream(it.fileDescriptor)
                val channel = inputStream.channel
                channel.map(
                    java.io.channels.FileChannel.MapMode.READ_ONLY,
                    it.startOffset,
                    it.declaredLength
                )
            }
            interpreter = Interpreter(modelBuffer, options)
        } catch (e: Exception) {
            Log.e(TAG, "加载声音模型失败", e)
        }
    }
    
    private fun classifyAudio(audioBuffer: ByteBuffer): List<Pair<String, Double>> {
        val interpreter = this.interpreter ?: return emptyList()
        
        // BirdNET声音分类
        val output = Array(1) { FloatArray(1000) } // 假设1000个类别
        interpreter.run(audioBuffer, output)
        
        // 取top5
        return output[0].mapIndexed { index, score ->
            Pair(getBirdName(index), score.toDouble())
        }.sortedByDescending { it.second }
        .take(5)
        .filter { it.second > 0.3 } // 过滤低置信度
    }
    
    private fun getBirdName(index: Int): String {
        // 从标签文件读取
        return context.assets.open("sound_labels.txt").bufferedReader().readLines()
            .getOrElse(index) { "Unknown" }
    }
}

第八步:声音识别界面

  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
// ui/screens/sound/SoundScreen.kt
package com.dongniao.app.ui.screens.sound

import android.Manifest
import android.os.Build
import androidx.compose.animation.core.*
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import kotlinx.coroutines.delay
import java.text.SimpleDateFormat
import java.util.*

@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
@Composable
fun SoundScreen() {
    val context = LocalContext.current
    val audioRecorder = remember { AudioRecorder(context) }
    
    var isRecording by remember { mutableStateOf(false) }
    var recordingDuration by remember { mutableStateOf(0L) }
    var detectedBirds by remember { mutableStateOf<List<Pair<String, Double>>>(emptyList()) }
    
    // 麦克风权限
    val permissionState = rememberPermissionState(Manifest.permission.RECORD_AUDIO)
    
    // 计时器
    LaunchedEffect(isRecording) {
        if (isRecording) {
            while (isRecording) {
                delay(100)
                recordingDuration += 100
            }
        } else {
            recordingDuration = 0
        }
    }
    
    // 鸟类检测回调
    LaunchedEffect(Unit) {
        audioRecorder.onBirdDetected = { birds ->
            detectedBirds = birds
        }
    }
    
    DisposableEffect(Unit) {
        onDispose {
            audioRecorder.stopRecording()
        }
    }
    
    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(
                Brush.verticalGradient(
                    colors = listOf(
                        Color(0xFF1B5E20),
                        Color(0xFF2E7D32)
                    )
                )
            )
    ) {
        Column(
            modifier = Modifier.fillMaxSize(),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Spacer(modifier = Modifier.height(60.dp))
            
            // 标题
            Text(
                text = "🎤 听声辨鸟",
                fontSize = 28.sp,
                fontWeight = FontWeight.Bold,
                color = Color.White
            )
            
            Spacer(modifier = Modifier.height(8.dp))
            
            // 状态文字
            Text(
                text = if (isRecording) {
                    "正在录音... ${formatDuration(recordingDuration)}"
                } else {
                    "点击按钮开始录音"
                },
                color = Color.White.copy(alpha = 0.7f),
                fontSize = 16.sp
            )
            
            Spacer(modifier = Modifier.height(40.dp))
            
            // 波形动画
            if (isRecording) {
                AudioWaveform(modifier = Modifier.height(100.dp))
            }
            
            Spacer(modifier = Modifier.weight(1f))
            
            // 检测结果
            if (detectedBirds.isNotEmpty()) {
                Card(
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(horizontal = 32.dp),
                    colors = CardDefaults.cardColors(
                        containerColor = Color.Black.copy(alpha = 0.3f)
                    )
                ) {
                    Column(
                        modifier = Modifier.padding(16.dp)
                    ) {
                        detectedBirds.take(3).forEach { (name, confidence) ->
                            Row(
                                modifier = Modifier
                                    .fillMaxWidth()
                                    .padding(vertical = 4.dp),
                                horizontalArrangement = Arrangement.SpaceBetween
                            ) {
                                Text(
                                    text = name,
                                    color = Color.White,
                                    fontSize = 16.sp
                                )
                                Text(
                                    text = "${(confidence * 100).toInt()}%",
                                    color = Color(0xFF81C784),
                                    fontWeight = FontWeight.Bold,
                                    fontSize = 16.sp
                                )
                            }
                        }
                    }
                }
            }
            
            Spacer(modifier = Modifier.height(32.dp))
            
            // 录音按钮
            Button(
                onClick = {
                    if (isRecording) {
                        audioRecorder.stopRecording()
                        isRecording = false
                    } else {
                        if (permissionState.status.isGranted) {
                            audioRecorder.startRecording()
                            isRecording = true
                        } else {
                            permissionState.launchPermissionRequest()
                        }
                    }
                },
                modifier = Modifier.size(100.dp),
                shape = CircleShape,
                colors = ButtonDefaults.buttonColors(
                    containerColor = if (isRecording) Color.Red else Color.White
                )
            ) {
                Icon(
                    imageVector = if (isRecording) Icons.Default.Stop 
                                 else Icons.Default.Mic,
                    contentDescription = if (isRecording) "停止" else "录音",
                    tint = Color(0xFF2E7D32),
                    modifier = Modifier.size(40.dp)
                )
            }
            
            Spacer(modifier = Modifier.height(16.dp))
            
            Text(
                text = if (isRecording) {
                    "请将手机靠近鸟类叫声"
                } else {
                    "录音5-10秒即可识别"
                },
                color = Color.White.copy(alpha = 0.6f),
                textAlign = TextAlign.Center
            )
            
            Spacer(modifier = Modifier.height(60.dp))
        }
    }
}

// 波形动画
@Composable
fun AudioWaveform(modifier: Modifier = Modifier) {
    val infiniteTransition = rememberInfiniteTransition()
    val phase by infiniteTransition.animateFloat(
        initialValue = 0f,
        targetValue = 2f * Math.PI.toFloat(),
        animationSpec = infiniteRepeatable(
            animation = tween(1000, easing = LinearEasing)
        )
    )
    
    Canvas(modifier = modifier) {
        val width = size.width
        val height = size.height
        val midY = height / 2
        
        for (x in 0f..width step 6f) {
            val normalizedX = x / width
            val amplitude = kotlin.math.sin(normalizedX * 6 * Math.PI + phase) * 30f
            
            drawLine(
                color = Color.White.copy(alpha = 0.7f),
                start = Offset(x, midY - amplitude),
                end = Offset(x, midY + amplitude),
                strokeWidth = 3f
            )
        }
    }
}

private fun formatDuration(millis: Long): String {
    val minutes = millis / 60000
    val seconds = (millis % 60000) / 1000
    return String.format("%02d:%02d", minutes, seconds)
}

第九步:鸟类图鉴

  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
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
// ui/screens/guide/GuideScreen.kt
package com.dongniao.app.ui.screens.guide

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.dongniao.app.data.model.BirdSpecies

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GuideScreen(
    onSpeciesClick: (Long) -> Unit
) {
    val viewModel: GuideViewModel = viewModel()
    val birds by viewModel.allBirds.collectAsState(initial = emptyList())
    val families by viewModel.allFamilies.collectAsState(initial = emptyList())
    
    var searchText by remember { mutableStateOf("") }
    var selectedFamily by remember { mutableStateOf<String?>(null) }
    
    val filteredBirds = birds.filter { bird ->
        (searchText.isEmpty() || bird.nameCn.contains(searchText) || 
         bird.nameEn.contains(searchText)) &&
        (selectedFamily == null || bird.family == selectedFamily)
    }
    
    Column(modifier = Modifier.fillMaxSize()) {
        // 搜索栏
        SearchBar(
            query = searchText,
            onQueryChange = { searchText = it },
            onSearch = {},
            active = false,
            onActiveChange = {},
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp),
            placeholder = { Text("搜索鸟类...") },
            leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }
        ) {}
        
        // 科级筛选
        LazyRow(
            modifier = Modifier.padding(horizontal = 16.dp),
            horizontalArrangement = Arrangement.spacedBy(8.dp)
        ) {
            item {
                FilterChip(
                    selected = selectedFamily == null,
                    onClick = { selectedFamily = null },
                    label = { Text("全部") }
                )
            }
            items(families) { family ->
                FilterChip(
                    selected = selectedFamily == family,
                    onClick = { 
                        selectedFamily = if (selectedFamily == family) null else family 
                    },
                    label = { Text(family) }
                )
            }
        }
        
        Spacer(modifier = Modifier.height(8.dp))
        
        // 鸟类列表
        LazyColumn(
            modifier = Modifier.fillMaxSize(),
            contentPadding = PaddingValues(horizontal = 16.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp)
        ) {
            items(filteredBirds, key = { it.id }) { bird ->
                BirdCard(bird = bird, onClick = { onSpeciesClick(bird.id) })
            }
        }
    }
}

@Composable
fun BirdCard(bird: BirdSpecies, onClick: () -> Unit) {
    Card(
        modifier = Modifier
            .fillMaxWidth()
            .clickable(onClick = onClick),
        elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
    ) {
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(12.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            // 鸟类图标
            Box(
                modifier = Modifier
                    .size(60.dp)
                    .clip(CircleShape)
                    .background(Color(0xFFE8F5E9)),
                contentAlignment = Alignment.Center
            ) {
                Text(
                    text = bird.nameCn.first().toString(),
                    fontSize = 24.sp,
                    fontWeight = FontWeight.Bold,
                    color = Color(0xFF2E7D32)
                )
            }
            
            Spacer(modifier = Modifier.width(12.dp))
            
            // 信息区
            Column(modifier = Modifier.weight(1f)) {
                Text(
                    text = bird.nameCn,
                    fontSize = 18.sp,
                    fontWeight = FontWeight.Bold
                )
                Text(
                    text = bird.nameEn,
                    fontSize = 13.sp,
                    color = Color.Gray
                )
                Spacer(modifier = Modifier.height(4.dp))
                Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
                    Tag(text = bird.family, color = Color(0xFF81C784))
                    Tag(
                        text = bird.conservationStatus,
                        color = if (bird.conservationStatus.contains("危")) 
                            Color(0xFFFFCDD2) else Color(0xFFE8F5E9)
                    )
                }
            }
            
            // 收藏图标
            if (bird.isFavorite) {
                Icon(
                    imageVector = Icons.Default.Favorite,
                    contentDescription = "已收藏",
                    tint = Color.Red,
                    modifier = Modifier.size(20.dp)
                )
            }
            
            Icon(
                imageVector = Icons.Default.ChevronRight,
                contentDescription = null,
                tint = Color.Gray
            )
        }
    }
}

@Composable
fun Tag(text: String, color: Color) {
    Surface(
        color = color.copy(alpha = 0.3f),
        shape = RoundedCornerShape(12.dp)
    ) {
        Text(
            text = text,
            fontSize = 11.sp,
            modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)
        )
    }
}

// 物种详情页
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SpeciesDetailScreen(speciesId: Long) {
    val viewModel: GuideViewModel = viewModel()
    val species by viewModel.getSpecies(speciesId).collectAsState(initial = null)
    
    species?.let { bird ->
        Scaffold(
            topBar = {
                TopAppBar(
                    title = { Text(bird.nameCn) },
                    navigationIcon = {
                        IconButton(onClick = { /* 返回 */ }) {
                            Icon(Icons.Default.ArrowBack, contentDescription = "返回")
                        }
                    },
                    actions = {
                        IconButton(onClick = { viewModel.toggleFavorite(bird.id) }) {
                            Icon(
                                imageVector = Icons.Default.Favorite,
                                contentDescription = "收藏",
                                tint = if (bird.isFavorite) Color.Red else Color.Gray
                            )
                        }
                    }
                )
            }
        ) { paddingValues ->
            LazyColumn(
                modifier = Modifier
                    .fillMaxSize()
                    .padding(paddingValues),
                contentPadding = PaddingValues(16.dp)
            ) {
                // 基本信息
                item {
                    Column(
                        modifier = Modifier.fillMaxWidth(),
                        horizontalAlignment = Alignment.CenterHorizontally
                    ) {
                        Text(
                            text = bird.nameEn,
                            fontSize = 18.sp,
                            color = Color.Gray
                        )
                        Text(
                            text = bird.scientificName,
                            fontSize = 14.sp,
                            color = Color.Gray,
                            fontStyle = FontStyle.Italic
                        )
                        Spacer(modifier = Modifier.height(8.dp))
                        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
                            Tag(text = bird.family, color = Color(0xFF2E7D32))
                            Tag(text = bird.conservationStatus, color = Color(0xFF1976D2))
                        }
                    }
                }
                
                item { Spacer(modifier = Modifier.height(16.dp)) }
                
                // 详细信息
                item {
                    Card(modifier = Modifier.fillMaxWidth()) {
                        Column(modifier = Modifier.padding(16.dp)) {
                            InfoRow(icon = Icons.Default.LocationOn, title = "栖息地", content = bird.habitat)
                            InfoRow(icon = Icons.Default.Map, title = "分布", content = bird.distribution)
                            InfoRow(icon = Icons.Default.Restaurant, title = "食性", content = bird.diet)
                            InfoRow(icon = Icons.Default.GraphicEq, title = "叫声", content = bird.voiceDescription)
                            InfoRow(icon = Icons.Default.NestEggs, title = "筑巢", content = bird.nesting)
                            InfoRow(icon = Icons.Default.CompareArrows, title = "迁徙", content = bird.migration)
                        }
                    }
                }
                
                item { Spacer(modifier = Modifier.height(16.dp)) }
                
                // 描述
                item {
                    Card(modifier = Modifier.fillMaxWidth()) {
                        Column(modifier = Modifier.padding(16.dp)) {
                            Text(
                                text = "详细描述",
                                fontSize = 16.sp,
                                fontWeight = FontWeight.Bold
                            )
                            Spacer(modifier = Modifier.height(8.dp))
                            Text(
                                text = bird.descriptionText,
                                fontSize = 14.sp,
                                lineHeight = 20.sp
                            )
                        }
                    }
                }
            }
        }
    }
}

@Composable
fun InfoRow(icon: ImageVector, title: String, content: String) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .padding(vertical = 8.dp),
        verticalAlignment = Alignment.Top
    ) {
        Icon(
            imageVector = icon,
            contentDescription = null,
            tint = Color(0xFF2E7D32),
            modifier = Modifier.size(20.dp)
        )
        Spacer(modifier = Modifier.width(12.dp))
        Column(modifier = Modifier.weight(1f)) {
            Text(
                text = title,
                fontSize = 12.sp,
                fontWeight = FontWeight.Bold,
                color = Color(0xFF2E7D32)
            )
            Text(
                text = content,
                fontSize = 14.sp
            )
        }
    }
}

第十步:观察日记

  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
// ui/screens/journal/JournalScreen.kt
package com.dongniao.app.ui.screens.journal

import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.rememberAsyncImagePainter
import com.dongniao.app.data.model.ObservationWithBird
import java.io.File
import java.text.SimpleDateFormat
import java.util.*

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun JournalScreen() {
    val viewModel: JournalViewModel = viewModel()
    val observations by viewModel.allObservations.collectAsState(initial = emptyList())
    val totalObservations by viewModel.totalObservations.collectAsState(initial = 0)
    val totalSpecies by viewModel.totalSpecies.collectAsState(initial = 0)
    
    Scaffold(
        topBar = {
            TopAppBar(title = { Text("📝 观察日记") })
        }
    ) { paddingValues ->
        if (observations.isEmpty()) {
            // 空状态
            Box(
                modifier = Modifier
                    .fillMaxSize()
                    .padding(paddingValues),
                contentAlignment = Alignment.Center
            ) {
                Column(horizontalAlignment = Alignment.CenterHorizontally) {
                    Icon(
                        imageVector = Icons.Default.Bird,
                        contentDescription = null,
                        modifier = Modifier.size(80.dp),
                        tint = Color(0xFF81C784)
                    )
                    Spacer(modifier = Modifier.height(16.dp))
                    Text(
                        text = "还没有观察记录",
                        fontSize = 18.sp,
                        fontWeight = FontWeight.Bold
                    )
                    Text(
                        text = "去拍照识别鸟类,记录你的观鸟之旅吧!",
                        fontSize = 14.sp,
                        color = Color.Gray
                    )
                }
            }
        } else {
            LazyColumn(
                modifier = Modifier
                    .fillMaxSize()
                    .padding(paddingValues),
                contentPadding = PaddingValues(16.dp),
                verticalArrangement = Arrangement.spacedBy(8.dp)
            ) {
                // 统计卡片
                item {
                    StatsCard(
                        totalObservations = totalObservations,
                        totalSpecies = totalSpecies
                    )
                }
                
                // 观察列表
                item {
                    Text(
                        text = "最近观察",
                        fontSize = 16.sp,
                        fontWeight = FontWeight.Bold,
                        modifier = Modifier.padding(vertical = 8.dp)
                    )
                }
                
                items(observations, key = { it.observation.id }) { obs ->
                    ObservationCard(observation = obs)
                }
            }
        }
    }
}

@Composable
fun StatsCard(totalObservations: Int, totalSpecies: Int) {
    Card(
        modifier = Modifier.fillMaxWidth(),
        colors = CardDefaults.cardColors(
            containerColor = Color(0xFFE8F5E9)
        )
    ) {
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp),
            horizontalArrangement = Arrangement.SpaceEvenly
        ) {
            StatItem(icon = Icons.Default.Bird, value = "$totalObservations", label = "总观察")
            StatItem(icon = Icons.Default.Leaf, value = "$totalSpecies", label = "已识别鸟种")
            StatItem(icon = Icons.Default.CalendarMonth, value = "7", label = "观鸟天数")
        }
    }
}

@Composable
fun StatItem(icon: ImageVector, value: String, label: String) {
    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        Icon(
            imageVector = icon,
            contentDescription = null,
            tint = Color(0xFF2E7D32),
            modifier = Modifier.size(24.dp)
        )
        Text(
            text = value,
            fontSize = 20.sp,
            fontWeight = FontWeight.Bold
        )
        Text(
            text = label,
            fontSize = 12.sp,
            color = Color.Gray
        )
    }
}

@Composable
fun ObservationCard(observation: ObservationWithBird) {
    val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) }
    
    Card(modifier = Modifier.fillMaxWidth()) {
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(12.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            // 缩略图
            val imageFile = File(observation.observation.imagePath)
            if (imageFile.exists()) {
                Image(
                    painter = rememberAsyncImagePainter(model = imageFile),
                    contentDescription = null,
                    modifier = Modifier
                        .size(60.dp)
                        .clip(RoundedCornerShape(8.dp)),
                    contentScale = ContentScale.Crop
                )
            } else {
                Box(
                    modifier = Modifier
                        .size(60.dp)
                        .clip(RoundedCornerShape(8.dp))
                        .background(Color(0xFFE8F5E9)),
                    contentAlignment = Alignment.Center
                ) {
                    Icon(
                        imageVector = Icons.Default.Bird,
                        contentDescription = null,
                        tint = Color(0xFF2E7D32)
                    )
                }
            }
            
            Spacer(modifier = Modifier.width(12.dp))
            
            Column(modifier = Modifier.weight(1f)) {
                Text(
                    text = observation.species?.nameCn ?: "未知鸟类",
                    fontSize = 16.sp,
                    fontWeight = FontWeight.Bold
                )
                Text(
                    text = dateFormat.format(Date(observation.observation.observedAt)),
                    fontSize = 12.sp,
                    color = Color.Gray
                )
                Row(verticalAlignment = Alignment.CenterVertically) {
                    Icon(
                        imageVector = Icons.Default.LocationOn,
                        contentDescription = null,
                        modifier = Modifier.size(12.dp),
                        tint = Color.Gray
                    )
                    Text(
                        text = String.format(
                            "%.4f, %.4f",
                            observation.observation.latitude,
                            observation.observation.longitude
                        ),
                        fontSize = 11.sp,
                        color = Color.Gray
                    )
                }
            }
            
            // 置信度
            Surface(
                color = Color(0xFFE8F5E9),
                shape = RoundedCornerShape(4.dp)
            ) {
                Text(
                    text = "${observation.observation.confidence}%",
                    fontSize = 12.sp,
                    fontWeight = FontWeight.Bold,
                    color = Color(0xFF2E7D32),
                    modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
                )
            }
        }
    }
}

第十一步: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
// ui/screens/chat/ChatScreen.kt
package com.dongniao.app.ui.screens.chat

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch

data class ChatMessage(
    val id: String = java.util.UUID.randomUUID().toString(),
    val role: String, // "user" or "assistant"
    val content: String,
    val timestamp: Long = System.currentTimeMillis()
)

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatScreen() {
    val viewModel: ChatViewModel = viewModel()
    val messages by viewModel.messages.collectAsState()
    val isLoading by viewModel.isLoading.collectAsState()
    
    var inputText by remember { mutableStateOf("") }
    val listState = rememberLazyListState()
    val coroutineScope = rememberCoroutineScope()
    
    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("🐦 观鸟助手") },
                colors = TopAppBarDefaults.topAppBarColors(
                    containerColor = Color(0xFF2E7D32),
                    titleContentColor = Color.White
                )
            )
        }
    ) { paddingValues ->
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(paddingValues)
        ) {
            // 消息列表
            LazyColumn(
                modifier = Modifier
                    .weight(1f)
                    .fillMaxWidth(),
                state = listState,
                contentPadding = PaddingValues(16.dp),
                verticalArrangement = Arrangement.spacedBy(12.dp)
            ) {
                items(messages, key = { it.id }) { message ->
                    MessageBubble(message = message)
                }
                
                if (isLoading) {
                    item {
                        TypingIndicator()
                    }
                }
            }
            
            // 自动滚动到底部
            LaunchedEffect(messages.size) {
                if (messages.isNotEmpty()) {
                    listState.animateScrollToItem(messages.size - 1)
                }
            }
            
            // 输入栏
            Surface(
                modifier = Modifier.fillMaxWidth(),
                elevation = androidx.compose.ui.unit.dp.times(4)
            ) {
                Row(
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(8.dp),
                    verticalAlignment = Alignment.CenterVertically
                ) {
                    // 图片按钮
                    IconButton(onClick = { /* 打开相册 */ }) {
                        Icon(
                            imageVector = Icons.Default.Photo,
                            contentDescription = "图片",
                            tint = Color(0xFF2E7D32)
                        )
                    }
                    
                    // 输入框
                    OutlinedTextField(
                        value = inputText,
                        onValueChange = { inputText = it },
                        modifier = Modifier.weight(1f),
                        placeholder = { Text("问我关于鸟类的问题...") },
                        shape = RoundedCornerShape(24.dp),
                        singleLine = true,
                        colors = OutlinedTextFieldDefaults.colors(
                            unfocusedBorderColor = Color(0xFF81C784),
                            focusedBorderColor = Color(0xFF2E7D32)
                        )
                    )
                    
                    Spacer(modifier = Modifier.width(8.dp))
                    
                    // 发送按钮
                    IconButton(
                        onClick = {
                            if (inputText.isNotBlank()) {
                                viewModel.sendMessage(inputText)
                                inputText = ""
                                coroutineScope.launch {
                                    listState.animateScrollToItem(messages.size)
                                }
                            }
                        },
                        enabled = inputText.isNotBlank() && !isLoading
                    ) {
                        Icon(
                            imageVector = Icons.Default.Send,
                            contentDescription = "发送",
                            tint = if (inputText.isNotBlank()) Color(0xFF2E7D32) 
                                   else Color.Gray
                        )
                    }
                }
            }
        }
    }
}

@Composable
fun MessageBubble(message: ChatMessage) {
    val isUser = message.role == "user"
    
    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start
    ) {
        if (!isUser) {
            // 助手头像
            Box(
                modifier = Modifier
                    .size(32.dp)
                    .clip(CircleShape)
                    .background(Color(0xFF2E7D32)),
                contentAlignment = Alignment.Center
            ) {
                Text("🐦", fontSize = 16.sp)
            }
            Spacer(modifier = Modifier.width(8.dp))
        }
        
        Surface(
            shape = RoundedCornerShape(
                topStart = if (isUser) 16.dp else 4.dp,
                topEnd = if (isUser) 4.dp else 16.dp,
                bottomStart = 16.dp,
                bottomEnd = 16.dp
            ),
            color = if (isUser) Color(0xFF2E7D32) else Color(0xFFF5F5F5)
        ) {
            Text(
                text = message.content,
                modifier = Modifier.padding(12.dp),
                color = if (isUser) Color.White else Color.Black,
                fontSize = 15.sp,
                lineHeight = 20.sp
            )
        }
        
        if (isUser) {
            Spacer(modifier = Modifier.width(8.dp))
        }
    }
}

@Composable
fun TypingIndicator() {
    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.Start
    ) {
        Box(
            modifier = Modifier
                .size(32.dp)
                .clip(CircleShape)
                .background(Color(0xFF2E7D32)),
            contentAlignment = Alignment.Center
        ) {
            Text("🐦", fontSize = 16.sp)
        }
        Spacer(modifier = Modifier.width(8.dp))
        
        Surface(
            shape = RoundedCornerShape(4.dp, 16.dp, 16.dp, 16.dp),
            color = Color(0xFFF5F5F5)
        ) {
            Row(
                modifier = Modifier.padding(12.dp),
                horizontalArrangement = Arrangement.spacedBy(4.dp)
            ) {
                repeat(3) { i ->
                    Box(
                        modifier = Modifier
                            .size(8.dp)
                            .clip(CircleShape)
                            .background(Color.Gray)
                    )
                }
            }
        }
    }
}

踩坑记录

1. TFLite GPU Delegate兼容性

用SwiftUI打造懂鸟App:iOS原生观鸟助手完整开发指南

为什么选SwiftUI原生?

iOS原生开发在这类项目上有天然优势:

优势 具体体现
相机性能 AVFoundation零延迟预览,原生HDR
AI推理 CoreML + Neural Engine,比跨平台快3-5倍
音频处理 AVAudioEngine原生支持,BirdNET直接调用
包体积 模型用ONNX Runtime Core,APK 30MB以内
用户体验 原生动画、手势、Haptic,观鸟App就是要手感好

技术架构

 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
┌─────────────────────────────────────────────────┐
│                 懂鸟 iOS App                     │
│                  (SwiftUI)                      │
├─────────────────────────────────────────────────┤
│                                                 │
│  ┌──────────┐  ┌──────────┐  ┌──────────────┐ │
│  │ 拍照识鸟  │  │ 声音识别  │  │   鸟类图鉴   │ │
│  │ (AVFound) │  │(AudioEng) │  │ (SwiftData) │ │
│  └────┬─────┘  └────┬─────┘  └──────┬───────┘ │
│       │             │               │          │
│  ┌────▼─────────────▼───────────────▼───────┐ │
│  │            本地推理引擎                    │ │
│  │  ┌────────┐  ┌──────────┐  ┌──────────┐ │ │
│  │  │ CoreML │  │ BirdNET  │  │ SQLite   │ │ │
│  │  │(图像)  │  │ (声音)   │  │(知识库)  │ │ │
│  │  └────────┘  └──────────┘  └──────────┘ │ │
│  └──────────────────────────────────────────┘ │
│                      │                         │
│  ┌───────────────────▼──────────────────────┐ │
│  │              云端服务                      │ │
│  │  ┌────────┐  ┌────────┐  ┌────────────┐ │ │
│  │  │GPT-5.4  │  │CloudKit│  │  App Store  │ │ │
│  │  └────────┘  └────────┘  └────────────┘ │ │
│  └──────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘

项目初始化

1
2
3
4
5
6
7
8
9
# 创建iOS项目
xcodebuild -create-xcproject -name DongNiao -type app
cd DongNiao

# SPM依赖
# 在Xcode中添加以下Package Dependencies:
# 1. https://github.com/nicklama/BirdNET-Analyzer (BirdNET CoreML)
# 2. https://github.com/nicklama/BirdNET-Analyzer-Swift (Swift封装)
# 3. https://github.com/nicklama/BirdNET-Analyzer (声音识别)

或者直接用SPM:

Git 高级技巧实战:bisect定位Bug、interactive rebase清理提交历史、cherry-pick精准搬运

前言

日常开发中,我们最常用的 Git 命令无非是 addcommitpushpullmerge。但当项目逐渐复杂,团队协作越来越频繁时,仅靠这些基础操作常常力不从心:线上突然出现一个诡异的 Bug,你不知道是哪个提交引入的;合并分支后提交历史乱成一团麻;线上需要紧急修复一个 Bug,但那个修复还夹杂在一堆无关的提交中。