每位移动开发者都曾经历过这样一种挫败感:你正在开发一个Flutter应用程序,却想要添加某种人工智能功能。

比如让程序能够读取图片并描述其中的内容,或者分析文本并返回结构化的结果。

然而突然之间,你会发现自己被各种特定于某个服务提供商的SDK、定制的JSON解析代码、手工编写的HTTP封装层所困扰,而且完全无法了解底层模型实际上在做什么。这时你已经不再是在开发应用程序本身,而是在搭建基础设施了。

Genkit正是为了解决这类问题而诞生的。而随着Genkit Dart的出现,现在地球上每一位Dart和Flutter开发者都能使用这一解决方案。

在本指南中,你将了解到Genkit Dart是什么、它的运作原理、它能完成哪些主要功能,以及为什么在编写任何一行Flutter代码之前了解这些内容是如此重要。

一旦掌握了这些基础知识,你就可以构建一个完整的物品识别应用程序:它能够打开设备的摄像头拍摄图片,然后将图片发送给多模态人工智能模型,最终得到关于所拍摄对象的结构化、类型化的描述结果。

目录

  • 先决条件

  • 什么是Genkit?

  • 为什么Genkit Dart对Flutter开发者来说意义重大

  • 核心概念

  • Genkit Dart支持的所有AI服务提供商

  • 流程:Genkit的核心组成部分

  • 利用Schemantic实现类型安全

  • 工具调用

  • 流式响应

  • 多模态输入

  • 结构化输出

  • 开发者用户界面

  • 在Flutter中运行Genkit:三种架构模式

  • 部署

  • 可观测性与追踪功能

  • 构建实时物品识别应用程序

  • 截图

  • 架构图

  • Genkit Dart的发展方向

  • 结论

  • 参考资料

  • 先决条件

    要按照本指南构建物品识别项目,您需要满足一些技术要求。请确保您的环境配置了以下版本或更高版本的软件:

    1. 必须使用 Dart SDK 3.5.0 或更高版本,才能支持最新的宏和类型系统功能。

    2. Flutter SDK 3.24.0 或更高版本可确保与最新的插件架构兼容。

    3. 需要来自受支持提供商的 API 密钥。对于本指南而言,我推荐使用 Google AI Studio 的 Gemini API 密钥。

    4. 应具备对 Dart 中异步编程的基本了解,尤其是要熟悉 `Future` 和 `await` 关键字的使用。

    您还需要一台支持摄像功能的物理设备或模拟器来测试该项目。由于我们需要捕获图像并进行处理,因此使用物理移动设备通常能提供最可靠的测试体验。

    Genkit 是什么?

    Genkit 是由 Google 开发的一个开源框架,用于构建基于人工智能的应用程序。它并不是为任何特定的编程语言或运行时环境而设计的。从最初发布起,该框架就已经支持 TypeScript 和 Go 语言,后来又扩展到了 Python,最近还添加了对 Dart 的支持。

    每种语言版本的 Genkit 都遵循相同的设计理念:为开发者提供一种统一、与具体提供商无关的方法,以便定义、运行、测试和部署人工智能逻辑。

    在这里,“框架”这个词具有特定的含义。Genkit 并不是简单地将某个提供商的 API 包装起来;它实际上是一个功能完备的工具包,包含了模型抽象层、流程定义系统、用于生成结构化数据的模式系统、工具调用接口、流处理支持、多智能体模式辅助工具、以及用于追踪应用程序中所有操作和数据流动的可观测性机制。

    Genkit 还提供了一个可以在本地主机上运行的可视化开发界面,因此您无需编写测试代码即可查看和测试各项功能。

    对于 Dart 开发者来说,这一点尤为重要。因为 Genkit Dart 并不是简单地将 TypeScript 版本的代码替换成 Dart 语法后得到的版本;它是一个完全为 Dart 语言量身定制的实现方案,其使用体验与常规的 Dart 代码非常相似,并且可以通过 CLI 工具直接融入 Flutter 的开发流程中。

    它解决的问题

    当您直接使用某个模型提供商时,每个提供商都构成了一个独立的系统。如果您最初使用的是 Google 的 Gemini API,后来又想将结果与 Anthropic 的 Claude 进行比较,那么您就需要添加第二个 SDK,学习第二种 API 规范,并编写适配代码来统一这两种不同的响应格式。

    如果对于某个特定的处理流程,您认为 xAI 的 Grok 更适合使用,因为它在处理某种类型的推理任务时表现更好,那么您又需要添加第三个 SDK。

    三种SDK、三种认证方式、三种响应解析机制——然而在这些方面却完全不存在任何统一的监控或观测机制。

    Genkit将这一切整合成了一个统一的接口。在初始化Genkit时,你需要提供一组插件列表,这些插件代表了你想要使用的模型提供商。之后,无论底层的提供商是哪一个,你都可以直接调用ai.generate()方法。如果你想更换提供商,只需修改其中一个参数即可,而你的应用程序的其他代码部分则可以保持不变。

    这种设计方式与具体的模型类型无关,这也是Genkit架构设计中最为关键的一点。

    为什么Genkit Dart对Flutter开发者来说意义重大

    Flutter的核心理念一直是:用户只需编写一次应用程序逻辑,这些代码就能在Android、iOS、Web、macOS、Windows和Linux等各种平台上正常运行。Genkit Dart将这一理念具体应用到了人工智能领域——你只需要用Dart语言编写一次人工智能处理流程,然后这些流程就可以在任何支持Dart的环境中运行。

    这种设计方式带来的实际效果往往容易被人们低估。在大多数移动端人工智能架构中,移动客户端与人工智能后端之间存在明显的界限:客户端通常使用Kotlin、Swift或Dart等语言编写,而后端则使用Python、TypeScript或Go等语言。后端定义的数据结构用于描述输入数据和输出数据的具体格式,但这些结构在客户端并不存在;客户端只是发送和接收JSON格式的数据,而双方对这些数据的含义都有各自的解读。因此,当后端的数据结构发生变化时,客户端很可能会出现故障。

    但使用Genkit Dart的话,后端和Flutter客户端都使用Dart语言编写,它们共享相同的数据结构定义。当你的人工智能处理流程需要接收ScanRequest对象并返回ItemDescription对象时,服务器和Flutter应用程序都会使用相同的Dart类来处理这些数据。只要在某个地方修改了数据结构,Dart的类型系统就会自动检测到所有不匹配的地方,从而确保客户端和服务器之间的数据交换能够顺利进行。这种端到端的类型安全机制之所以能够实现,正是因为Dart语言在两端都被广泛使用。

    还需要说明的是,截至本文撰写时,Genkit Dart仍处于预览阶段,并不是1.0版本。某些API可能会发生变化,但它的核心功能、流程处理系统、模型抽象层、数据结构集成机制以及命令行工具都已经足够稳定,可以用来开发正式的应用程序了,而且它的发展方向明确地指向最终版的发布。

    核心概念

    在详细研究代码之前,先了解构成每一个Genkit应用程序的四个基本要素会有所帮助。

    Genkit实例

    在任何Genkit应用程序中,第一步都是创建一个Genkit实例。这个实例包含了你的应用程序的所有配置信息,包括哪些模型提供商插件是处于激活状态的。

    在创建这个实例时,你需要提供一组插件列表,之后就可以利用这个实例来注册处理流程、定义相关工具以及调用各种模型了。

    import 'package:genkit/genkit.dart';  
    import 'package:genkit/google_genai/genkit_google_genai.dart';  
    
    final ai = Genkit(plugins: [googleAI]);  
    

    Genkit构造函数接受一个plugins列表。每个插件都会将其模型和功能注册到实例中。一旦插件被注册成功,其模型就可以通过实例的generate方法来使用。

    插件

    插件是通用Genkit API与特定提供者的实际HTTP端点之间的桥梁。

    googleAI()函数就是一个例子——它负责配置能够与Google生成式AI服务进行交互的插件,使用环境中的API密钥对请求进行认证,并将Genkit的模型调用转换成Gemini API所要求的特定格式。你完全不需要自己编写这些转换代码,因为插件会全部处理这些工作。

    流程

    在Genkit中,流程是AI任务的基本单位。流程是一种Dart函数,它接受类型明确的输入数据,执行与AI相关的操作(可能是模型调用、一系列模型调用、工具的使用,或者这三者的组合),然后返回类型明确的输出结果。

    流程与普通函数的不同之处在于Genkit为它提供了许多额外的功能:追踪机制、可观测性支持、与开发者界面的集成能力、将流程作为HTTP端点进行使用的功能,以及对输入和输出数据类型的强制检查。

    ai.defineFlow()方法用于定义流程,而调用流程的方式也与普通函数完全相同。

    模式

    模式用于定义在AI操作中传入和传出数据的结构。这些模式是通过schemantic包来定义的,该包利用Dart代码生成技术,根据带有@Schema()注解的抽象类定义,生成类型严格的类。这意味着你的AI输入和输出数据不是映射对象或动态对象,而是具有编译时安全性的真正Dart类型。

    Genkit Dart支持的各类AI提供者

    这是Genkit的一大优势,值得专门介绍。在当前的预览版本中,Genkit Dart支持以下这些插件提供者。

    Google生成式AI(Gemini)

    package: 'genkit.google_genai'

    这个插件是通过Google AI Studio API密钥来访问Google的Gemini系列模型的。它涵盖了所有的Gemini模型,包括Gemini 2.5 Flash、Gemini 2.5 Pro,以及能够处理文本、图像、音频和视频的多模态版本。Gemini API的免费 tier功能非常丰富,因此它是非常适合初学者的首选。

    import 'package:genkit.google_genai/genkit_google_genai.dart';

    final ai = Genkit(plugins: [googleAI()]);

    final result = await ai.generate(
    model: googleAI.gemini('gemini-2.5-flash'),
    prompt: '尼日利亚的首都是哪个?',
    );

    API密钥会自动从GEMINI_API_KEY环境变量中读取。你只需设置一次这个密钥,之后每次调用该插件时都会自动使用它,而无需在代码中进行任何额外的配置。

    Google Vertex AI

    包名:genkit_vertexai

    Vertex AI是谷歌推出的企业级人工智能平台。与Google AI Studio不同,Vertex AI是通过Google Cloud身份凭证进行鉴权的,因此对于那些需要访问控制、审计日志、区域数据存储选项以及与其他Google Cloud服务集成的生产环境来说,它是一个非常合适的选择。此外,通过Google Cloud项目,还可以使用Gemini模型,同时还能利用这些模型进行向量搜索。

    import 'package:genkit_vertexai/genkit_vertexai.dart';
    
    final ai = Genkit(plugins: [
      vertexAI(projectId: 'your-project-id', location: 'us-central1'),
    ]);
    
    final result = await ai.generate(
      model: vertexAI.gemini('gemini-2.5-pro'),
      prompt: '总结以下合同条款...", 
    );
    

    Anthropic (Claude)

    包名:genkit_anthropic

    Anthropic的Claude模型可以直接通过Anthropic插件使用。Claude以其强大的推理能力、严格遵守指令的习惯,以及倾向于采取保守的态度而非产生幻觉而著称。如果你正在开发一个应用程序,在这种应用中,准确性和对模糊指令的谨慎处理比单纯的速度更为重要,那么将Claude纳入你的选择范围绝对是值得的。

    import 'package:genkit_anthropic/genkit_anthropic.dart';
    
    final ai = Genkit(plugins: [anthropic()]);
    
    final result = await ai.generate(
      model: anthropic.model('claude-opus-4-5'),
      prompt: '检查这段代码是否存在安全漏洞。', 
    );
    

    API密钥是从ANTHROPIC_API_KEY环境变量中获取的。

    OpenAI (GPT)及兼容APIs

    包名:genkit_openai

    这个插件实际上涵盖了两种不同的使用场景,理解这两种场景都非常重要。

    第一种使用场景非常直接:它允许你使用OpenAI的GPT-4o、GPT-4 Turbo以及其他OpenAI模型。许多团队已经在他们的基础设施中集成了OpenAI的相关服务,而这个插件可以帮助你将这些模型与Genkit平台中的其他服务一起使用,而无需学习额外的SDK。

    import 'package:genkit_openai/genkit_openai.dart';
     
    final ai = Genkit(plugins: [openAI]);
     
    final result = await ai.generate(
      model: openAI.model('gpt-4o'),
      prompt: '为以下函数编写一个单元测试。', 
    );
    

    API密钥是从OPENAI_API_KEY环境变量中获取的。

    第二种使用场景才是这个插件真正发挥作用的地方。openAI插件允许你设置一个自定义的baseUrl参数,这意味着它能够与任何遵循OpenAI请求和响应格式的HTTP API进行交互。事实上,已经有大量的服务提供商将OpenAI协议作为标准接口来使用了。

    实际应用中,这意味着在Genkit Dart中,无需额外安装任何包,就可以使用以下所有功能:

    xAI的 Grok模型可以通过将插件指向xAI的API端点来使用。Grok具有强大的推理能力和实时信息访问能力,因此将其作为替代方案或对比工具使用非常方便。
    ```dart
    final ai = Genkit(plugins: [
    openAI(
    apiKey: Platform.environment['XAI_API_KEY']!,
    baseUrl: 'https://api.x.ai/v1',
    models: [
    CustomModelDefinition(
    name: 'grok-3',
    info: ModelInfo(
    label: 'Grok 3',
    supports: {'multiturn': true, 'tools': true, 'systemRole': true},
    ),
    ),
    ],
    ),
    ]);
    final result = await ai.generate(
    model: openAI.model('grok-3'),
    prompt: '解释当前核聚变能源研究的发展状况。'
    );
    ```

    DeepSeek的模型,尤其是DeepSeek-R1和DeepSeek-V3,在推理和编码任务中表现优异,且成本相对较低,因此受到了广泛关注。使用这些模型的方法也与上述相同:
    ```dart
    final ai = Genkit(plugins: [
    openAI(
    apiKey: Platform.environment['DEEPSEEK_API_KEY']!,
    baseUrl: 'https://api.deepseek.com/v1',
    models: [
    CustomModelDefinition(
    name: 'deepseek-chat',
    info: ModelInfo(
    label: 'DeepSeek Chat',
    supports: {'multiturn': true, 'tools': true, 'systemRole': true},
    ),
    ),
    ],
    ),
    ]);
    final result = await ai.generate(
    model: openAI.model('deepseek-chat'),
    prompt: '优化这个Dart函数,以提高其内存使用效率。'
    );
    ```

    Groq的推理平台也可以通过同样的方式来使用。Groq以其极快的推理速度而闻名,在那些对响应延迟要求极高的应用中,它能够发挥重要作用。
    ```dart
    final ai = Genkit(plugins: [
    openAI(
    apiKey: Platform.environment['GROQ_API_KEY']!,
    baseUrl: 'https://api.groq.com/openai/v1',
    models: [
    CustomModelDefinition(
    name: 'llama-3.3-70b-versatile',
    info: ModelInfo(
    label: 'Llama 3.3 70B (Groq)',
    supports: {'multiturn': true, 'tools': true, 'systemRole': true},
    ),
    ),
    ],
    ),
    ]);
    ```

    AI以及其他与OpenAI兼容的推理工具,都是按照这种模式来使用的。你只需要更改`baseUrl`、`apiKey`环境变量的值以及模型名称即可,应用程序中的其他部分——数据流结构、schema定义、工具配置等——都保持不变。

    需要明确的是,AWS Bedrock和Azure AI Foundry目前还不支持这些功能。Genkit的TypeScript版本为这两个平台提供了专用插件,但截至目前,Dart版本的插件还尚未推出。

    如果你的组织使用的AI基础设施是基于AWS或Azure搭建的,那么目前的解决方案是在这些平台上部署TypeScript版本的Genkit后端,然后让Flutter客户端通过远程调用的方式来使用它。这种方案在本书的Flutter架构章节中有详细说明,是一种适用于生产环境的有效方法。

    使用 llamaDart 的本地模型

    包名:genkit_llamadart(社区插件)

    对于那些需要完全在设备上或使用自己的硬件来运行模型、且不依赖任何云服务的场景,genkit_llamadart这个社区插件可以通过 llamaDart 推理引擎在本地运行 GGUF 格式的模型。当数据隐私要求禁止将任何数据发送到第三方 API,或者当你需要具备离线功能的 AI 功能,又或者当你希望使用一个不会消耗 API 配额的开发环境时,这个插件就非常适用。

    import 'package:genkit/genkit.dart';
    import 'package:genkit_llamadart/genkit_llamadart.dart';
     
    void main() async {
      final plugin = llamaDart(
        models: [
          LlamaModelDefinition(
            name: 'local-llm',
            // 路径指向本地下载的 GGUF 模型文件
            modelPath: '/models/llama-3.2-3b-instruct.gguf',
            modelParams: ModelParams(contextSize: 4096),
          ),
        ],
      );
     
      final ai = Genkit(plugins: [plugin]);
     
      final result = await ai.generate(
        model: llamaDart.model('local-llm'),
        prompt: '总结这份文档的要点。',
        config: LlamaDartGenerationConfig(
          temperature: 0.3,
          maxTokens: 512,
          enableThinking: false,
        ),
      );
     
      print(result.text);
     
      // 使用完毕后释放插件以释放系统资源
      await plugin.dispose();
    }
    

    该插件支持文本生成、流式处理、工具调用循环、结构化响应的受限 JSON 输出,以及文本嵌入功能。GGUF 模型可以从 Hugging Face 或其他模型资源平台获取。

    适合进行本地实验的模型包括 Llama 3.2 3B Instruct(体积小但功能强大)、Phi-3 Mini(占用系统资源极少),以及 Google 的 Gemma 3 2B(小型开放权重模型)。

    Chrome 内置 AI (浏览器中的 Gemini Nano)

    包名:genkit_chrome(社区插件)

    对于 Flutter Web 应用来说,有一个插件可以直接在 Chrome 浏览器中利用其内置的 AI 功能来运行 Google 的 Gemini Nano 模型。这个插件不需要 API 密钥,也不需要进行网络请求或依赖任何服务器,模型完全在浏览器进程内部运行。

    import 'package:genkit/genkit.dart';
    import 'package:genkit_chrome/genkit_chrome.dart';
     
    void main() async {
      final ai = Genkit(plugins: [ChromeAIPlugin]);
     
      final stream = ai.generateStream(
        model: modelRef('chrome/gemini-nano'),
        prompt: '为这段文字提出三项改进建议。',
      );
     
      await for (final chunk in stream) {
        print(chunk.text);
      }
    }
    

    这个插件要求使用 Chrome 128 及更高版本,并且需要启用特定的浏览器配置选项。目前来看,这个插件还处于实验阶段,且仅支持文本处理功能。它的应用场景虽然较为特定,但确实存在:比如优先考虑离线功能的 Web 应用、低延迟的自动完成功能(在那种即使与快速服务器进行一次往返通信也会导致较大延迟的情况下),以及那些需要保护用户隐私的应用场景——在这些场景中,用户的文本绝对不能离开设备本身。

    关于提供商生态系统的说明

    在当前的预览阶段,Genkit Dart插件生态系统是有意地针对某些特定的提供商进行设计的。前四个第三方插件覆盖了最常用的提供商;与OpenAI兼容的机制使得无需添加新的插件就能使用大量其他服务;而社区开发的插件则可以满足本地环境及浏览器原生功能的需求。TypeScript版本提供的插件数量更多,随着Genkit Dart逐渐发展成稳定的版本,这一差距将会缩小。

    要了解新增了哪些功能,最可靠的方法就是关注pub.dev包命名空间中是否出现了新的genkit_*格式的插件。

    在不同提供商之间切换

    这个提供商列表最大的优势在于:在Genkit实例层面,你可以灵活地使用它们。你可以在同一应用程序中同时加载多个插件,并为不同的处理流程选择不同的提供商。

    import 'package:genkit/genkit.dart';
    import 'package:genkit_google_genai/genkit_google_genai.dart';
    import 'package:genkit_anthropic/genkit_anthropic.dart';
    import 'package:genkit_openai/genkit_openai.dart';
    
    final ai = Genkit(plugins: [
      googleAI(),
      anthropic(),
      openAI(),
    ]);
    
    // 使用Gemini处理多模态任务
    final visionResult = await ai.generate(
      model: googleAI.gemini('gemini-2.5-flash'),
      prompt: [
        Part.media(url: imageUrl),
        Part.text('这张图片里有什么?'),
      ],
    );
    
    // 使用Claude进行文档审核
    final reviewResult = await ai.generate(
      model: anthropic.model('claude-opus-4-5'),
      prompt: contractText,
    );
    
    // 使用GPT-4o生成代码
    final codeResult = await ai.generate(
      model: openAI.model('gpt-4o'),
      prompt: featureDescription,
    );
    

    以上三个示例都使用了相同的ai.generate()方法。不需要任何适配代码,也不需要进行转换操作,更无需为每个提供商分别设置认证机制。提供商之间的区别仅体现在model参数中。

    流程:Genkit的核心

    在Genkit中,“流程”是一个极其重要的概念。理解什么是流程,了解将AI逻辑封装在流程中能带来什么好处,以及了解流程是如何组合使用的,这些都能帮助你真正理解Genkit的功能所在。

    定义一个基本流程

    最简单的形式来说,可以通过ai.defineFlow()来定义一个流程:

    import 'package:genkit/genkit.dart';
    import 'package:genkit_google_genai/genkit-google_genai.dart';
    import 'package:schemantic/schemantic.dart';
    
    part 'main.g.dart';
    
    @Schema()
    abstract class $BookSummaryInput {
      String get title;
      String get author;
    }
    
    @Schema()
    abstract class $BookSummaryOutput {
      String get summary;
      String get keyThemes;
      int get estimatedReadTimeMinutes;
    }
    
    void main() async {
      final ai = Genkit(plugins: [googleAI()]);
    
      final bookSummaryFlow = ai.defineFlow(
        name: 'bookSummaryFlow',
        inputSchema: BookSummaryInput.$schema,
        outputSchema: BookSummaryOutput.$schema,
        fn: (input, context) async {
          final response = await ai.generate(
            model: googleAI.gemini('gemini-2.5-flash'),
            prompt: '请为${input.title}这本书'
                    '由${input.author}所写提供一份摘要。请包括关键主题和预计阅读时间。'
            outputSchema: BookSummaryOutput.$schema,
          );
    
          if (response.output == null) {
            throw Exception('模型没有返回有效的结构化响应。');
          }
    
          return response.output!;
        },
      );
    
      final summary = await bookSummaryFlow(
        BookSummaryInput(title: 'Things Fall Apart', author: 'Chinua Achebe'),
      );
    
      print(summary.summary);
      print('关键主题:${summary.keyThemes}`);
      print('预计阅读时间:${summary.estimatedReadTimeMinutes}分钟');
    }
    

    让我们逐一分析这段代码的每个部分。

    \(BookSummaryInput\)BookSummaryOutput上标注的@Schema()告诉包,这些抽象类应该对应有具体的Dart类。按照惯例,抽象类的名称前会加上美元符号。

    运行dart run build_runner build后,生成器会创建BookSummaryInputBookSummaryOutput这两个具体类,这些类会包含构造函数、JSON序列化功能,同时其Genkit模式定义也会作为$schema静态属性被添加进去。

    文件开头的part 'main.g.dart'指令用于引入生成的Dart代码,使其能够在程序中被使用。

    ai.defineFlow()方法接受一个名称、一个输入模式、一个输出模式,以及包含实际逻辑的函数fn。这个名称用于在开发者界面和命令行中识别该流程。输入模式输出模式能够确保类型安全性:Genkit会在调用fn之前验证输入数据,在将结果返回给调用者之前也会进行验证。

    fn函数内部,参数input的类型已经被定义为BookSummaryInput,因此你可以直接通过类型系统访问它的属性,而无需进行诸如input['title']这样的操作,也不需要对动态对象进行空值检查。

    在流程中调用的ai.generate()方法指定了模型、提示字符串以及相同的输出模式。该模型会根据模式要求生成符合BookSummaryOutput格式的JSON数据,Genkit会验证返回的JSON内容,并通过response.output将其作为类型为BookSummaryOutput的对象提供出来。

    最后的调用await bookSummaryFlow(BookSummaryInput(...))与调用普通函数的方式完全相同,其返回值的类型也是BookSummaryOutput

    为什么不直接调用ai.generate()呢?

    这是一个合理的疑问。如果你只需要执行一次模型调用,且不需要任何额外的逻辑处理,那么额外的定义步骤可能会显得有些多余。但实际上,将这样的调用封装在流程中会带来很多好处。

    首先,开发者界面可以发现并测试这些流程,但无法直接测试单纯的ai.generate()调用。当你定义了一个流程后,它就能立即在本地网页界面中被使用,而无需进行任何额外的配置。

    其次,只需一行代码就可以将流程暴露为HTTP接口,而单纯的ai.generate()调用是无法实现这一点的。Genkit AI逻辑的部署机制本质上是基于流程来设计的。

    第三,追踪和监控功能是针对流程层面进行的。在开发者界面中查看追踪信息时,你可以看到整个流程的执行过程:哪个模型被调用了、使用了什么提示信息、返回了什么结果、执行耗时多少、以及消耗了多少令牌。而这些都是通过普通的生成调用无法实现的。

    第四,流程是多步骤AI逻辑中的组成单元。你可以在另一个流程内部调用某个流程,构建一系列AI操作,并且能够独立地追踪和观察层次结构中的每一层。

    多步骤流程

    一个流程并不一定只包含一次模型调用。它可以包含任意数量的Dart逻辑代码,包括多次模型调用、条件语句、循环以及对外部API的调用。整个操作序列会被视为一次完整的流程执行。

    final productResearchFlow = ai.defineFlow(
      name: 'productResearchFlow',
      inputSchema: ProductQuery.$schema,
      outputSchema: ProductReport.$schema,
      fn: (input, context) async {
        // 第一次模型调用:提取结构化的搜索关键词
        final searchTermsResponse = await ai.generate(
          model: googleAI.gemini('gemini-2.5-flash'),
          prompt: '从这个产品查询中提取前5个搜索关键词:'
                  '"${input.query}". 将它们以逗号分隔的形式返回。',
        );
    
        final keywords = searchTermsResponse.text;
    
        // 外部API调用:使用这些关键词从数据库中获取产品信息
        final products = await fetchProductsFromDatabase(keywords);
    
        // 第二次模型调用:将获取到的信息整合成结构化的报告
        final reportResponse = await ai.generate(
          model: googleAI.gemini('gemini-2.5-pro'),
          prompt: '根据这些产品信息:$products\n\n'
                  '为${input.query}撰写一份简洁的竞争分析报告。',
          outputSchema: ProductReport.$schema,
        );
    
        if (reportResponse.output == null) {
          throw Exception('报告生成失败。');
        }
    
        return reportResponse.output!;
      },
    );
    

    请注意,这个流程使用了两种不同的Gemini模型来进行两次不同的操作:使用Flash模型来提取关键词,而使用Pro模型来生成分析报告。此外,它还在中间调用了一个外部Dart函数。整个执行过程,包括两次模型调用和那个外部函数调用,都被视为一次完整的流程执行。

    借助Schemantic实现类型安全

    schemantic包使得Genkit Dart真正体现了Dart的语言特性,而不会让人觉得它只是TypeScript的移植版本。充分理解这一机制非常重要,因为它是Genkit Dart中所有结构化输出和流程定义的基础。

    Schemantic的工作原理

    Schemantic是一个代码生成库。你可以通过编写带有getter声明的抽象类,并使用@Schema()注解来描述这些类的结构。当你运行dart run build_runner build命令时,该工具会读取这些抽象类,并生成具体的实现类,这些实现类会包含以下内容:

    • 一个构造函数,用于为每个字段指定参数名称

    • fromJson(Map<String, dynamic> json)工厂构造函数,用于数据反序列化

    • toJson()方法,用于数据序列化

    • 一个静态的$schema属性,其中存储了Genkit在运行时使用的模式定义。这些定义用于验证输入和输出的数据格式,并指导模型生成预期的输出结果

    @Field()注释允许你为单个属性添加元数据。其中最重要的元数据是description字符串,Genkit会在发送给模型的提示信息中包含这一字段。更详细的字段描述能够生成结构更加规整的输出结果,因为模型能更准确地理解每个字段应该包含哪些内容。

    import 'package:schemantic/schemantic.dart';
    
    part 'schemas.g.dart';
    
    @Schema()
    abstract class $ProductScan {
      @Field(description: '产品或对象的通用名称')
      String get productName;
    
      @Field(description: '该对象的主要材质')
      String get material;
    
      @Field(description: '预计的零售价格范围(单位:美元)')
      String get estimatedPriceRange;
    
      @Field(description: '任何可见的品牌名称或标志)
      String? get brandName;
    
      @Field(description: '物品状况的简短描述)
      String get condition;
    
      @Field(description: '置信度得分,范围在0.0到1.0之间')
      double get confidence;
    }
    

    运行构建工具后,你可以将ProductScan作为具体的类来使用:

    final scan = ProductScan(
      productName: '不锈钢水瓶',
      material: '不锈钢',
      estimatedPriceRange: '\\(20 - \\)40',
      brandName: 'Hydro Flask',
      condition: '几乎全新',
      confidence: 0.94,
    );
    
    print(scan.toJson());
    // {productName: Stainless Steel Water Bottle, material: Stainless steel, ...}
    

    在流式处理中也可以这样使用:

    final response = await ai.generate(
      model: googleAI.gemini('gemini-2.5-flash'),
      prompt: [...imageAndTextParts],
      outputSchema: ProductScan.$schema,
    );
    
    final ProductScan? result = response.output;
    if (result != null) {
      print(result.productName);
      print('置信度:${result.confidence}`);
    }
    

    response.output的类型是ProductScan?。编译器能够识别这一点,因此不会出现类型转换、动态访问数组元素或因字段名称导致的运行时错误。

    可为空字段

    在抽象类中用?标记为可空的属性,在生成的类中也会保持可空状态。Genkit会通过模式结构将这一属性的可空性信息传递给模型,这样模型就能知道哪些字段是可选的。这种方式可以有效避免出现错误的空值,同时也能防止那些模型根据输入数据确实无法确定的字段引发验证失败。

    列表与嵌套类型

    Schemantic能够正确处理列表和嵌套模式类型。被声明为List<String>的属性,在模式定义中会生成相应的数组类型;而被标记为另一个@Schema()注释类型的属性,则会生成对应的嵌套对象模式。

    @Schema()
    abstract class $ItemAnalysis {
      String get name;
      List<String>> get tags;
      List<>$RelatedItem>> get relatedItems;  // 嵌套模式引用
    }
    
    @Schema()
    abstract class $RelatedItem {
      String get name;
      String get relationship;
    }
    

    工具调用

    工具调用是一种机制,它使模型能够在生成响应的过程中采取行动并获取信息。

    当你定义了这些工具并让模型能够使用它们时,模型可以根据对话内容判断是否需要使用其中某一个工具。它会发出结构化的请求来调用该工具,Genkit会执行相应的功能并将结果返回给模型,之后模型便会利用这些新信息继续生成响应。

    正是这一机制使得模型从一个静态的知识库转变为能够获取实时数据、查询数据库、调用外部API并真正完成具体任务的系统。

    定义工具

    import 'package:schemantic/schemantic.dart';
    
    part 'tools.g.dart';
    
    @Schema()
    abstract class $StockPriceInput {
      @Field(description: '股票代码,例如AAPL、GOOG')
      String get ticker;
    }
    
    // 将该工具注册到Genkit实例中
    ai.defineTool(
      name: 'getStockPrice',
      description: '获取指定股票代码的当前市场价格',
      inputSchema: StockPriceInput.$schema,
      fn: (input, context) async {
        // 在实际应用中,这里会调用金融数据API
        final price = await StockDataService.fetchPrice(input.ticker);
        return '股票${input.ticker}的当前价格为:\$$price';
      },
    );
    

    对于工具本身及其输入字段来说,description字段至关重要。模型会根据这些描述来决定是否调用该工具以及如何构建输入数据。如果描述不够清晰,就会导致工具使用效果不佳。

    在流程中使用工具

    final marketAnalysisFlow = ai.defineFlow(
      name: 'marketAnalysisFlow',
      inputSchema: AnalysisRequest.$schema,
      outputSchema: MarketReport.$schema,
      fn: (input, context) async {
        final response = await ai.generate(
          model: googleAI.gemini('gemini-2.5-pro'),
          prompt: '请对以下公司进行简要的市场分析:'
                  '${input.companyTickers.join(', ')}. '
                  '在撰写分析报告之前,请先查询这些公司的当前价格。',
          toolNames: ['getStockPrice'],
          outputSchema: MarketReport.$schema,
        );
    
        if (response.output == null) {
          throw Exception('市场分析生成失败。');
        }
    
        return response.output!;
      },
    );
    

    toolNames参数是一个工具名称列表(这些名称应当与你在调用defineTool时指定的名称一致),你可以通过这个列表让模型在特定的处理流程中使用相应的工具。模型会根据这些描述和输入结构自动决定何时以及如何使用这些工具。

    工具调用循环

    当你提供了各种工具后,一次ai.generate()调用可能会涉及多次与模型的交互过程。具体的执行顺序如下:

    1. Genkit会将提示信息以及相关工具的配置信息发送给模型。

    2. 模型会回复要求,要求调用一个或多个工具,而不是直接生成最终文本。

    3. Genkit会执行这些被请求的工具,并收集它们的输出结果。

    4. Genkit会将这些工具的输出结果发送回模型。

    5. 模型随后可能会继续调用其他工具,或者最终生成响应结果。

    Genkit会自动完成所有这些步骤。对于你的应用程序代码来说,ai.generate()仍然只是一个需要等待其执行完成的单一调用而已;具体的工具处理流程是在内部自动运行的。

    流式响应

    大型语言模型是逐个词元生成文本的。在大多数API调用中,客户端会等到所有响应内容都被完全生成后才会收到结果。

    对于较短的响应来说,这种处理方式没有问题;但对于较长的响应而言,就会导致明显的延迟,从而影响用户体验。而流式传输技术则可以在文本生成的过程中就将其逐个词元发送给客户端。

    Genkit在ai.generate()层面以及整个处理流程的层面都支持流式传输功能。

    在生成阶段进行流式传输

    final stream = aigenerateStream(
      model: googleAI.gemini('gemini-2.5-flash'),
      prompt: '写一篇关于贝宁王国的详细历史文章。',
    );
    
    await for (final chunk in stream) {
      // 每个词元块都包含了自上一个词元块生成以来新增的内容
      process.stdout.write(chunk.text);
    }
    
    // 当所有词元块都被处理完毕之后,就可以得到完整的响应结果
    final completeResponse = await stream.onResult;
    print('\n\n总共使用了${completeResponse_usage?.totalTokens}个词元。');
    

    generateStream()方法会立即返回一个流对象。使用await for循环遍历这个流对象,就可以在每个词元块生成的同时对其进行处理。stream.onResult会在所有词元块都被处理完毕之后,返回完整的响应结果。

    在整体处理流程层面进行流式传输

    处理流程也可以用于流式传输中间生成的结果。这对于那些包含多步逻辑的处理流程来说非常有用——因为这样就可以在流程尚未完全完成之前,向用户展示当前的进展情况。

    @Schema()
    abstract class $StoryRequest {
      String get genre;
      String get protagonist;
    }
    
    @Schema()
    abstract class $StoryResult {
      String get title;
      String get fullText;
    }
    
    final storyGeneratorFlow = ai.defineFlow(
      name: 'storyGeneratorFlow',
      inputSchema: StoryRequest.$schema,
      outputSchema: StoryResult.$schema,
      streamSchema: JsonSchema.string(),
      fn: (input, context) async {
        // 在故事文本生成的过程中就将其逐个词元发送出去
        final stream = ai.generateStream(
          model: googleAI.gemini('gemini-2.5-flash'),
          prompt: '写一篇\({input.genre}风格的短篇小说,主角是\){input.protagonist}。',
        );
    
        final buffer = StringBuffer();
    
        await for (final chunk in stream) {
          buffer.write(chunk.text);
          if (context.streamingRequested) {
            // 将每个词元块发送给接收方
            context.sendChunk(chunk.text);
          }
        }
    
        final fullText = buffer.toString();
    
        // 单独生成一个标题
        final titleResponse = await ai.generate(
          model: googleAI.gemini('gemini-2.5-flash'),
          prompt: '为这篇故事生成一个简短的标题:$fullText',
        );
    
        return StoryResult(title: titleResponse.text.trim(), fullText: fullText);
      },
    );
    

    defineFlow方法中的streamSchema参数用于指定将通过context.sendChunk()流式传输的数据类型。在这里,数据类型被定义为字符串,这意味着每个传输的数据块都是一段文本。如果你的使用场景需要传输结构化对象,你也可以为这些结构化数据块定义相应的格式规范。

    要消费流式数据流,可以按照以下代码进行操作:

    final streamResponse = storyGeneratorFlow.stream(
      StoryRequest(genre: 'science fiction', protagonist: 'a Lagos street vendor'),
    );
    
    // 随着数据块的到达立即将其打印出来
    await for (final chunk in streamResponse.stream) {
      process.stdout.write(chunk);
    }
    
    // 在数据流结束后获取最终的结果
    final finalResult = await streamResponse.output;
    print('\n\n标题: ${finalResult.title}')
    

    在Flutter环境中,每当有数据块通过streamResponse.stream被传入时,都会触发一次setState()调用,从而更新相应的Text组件,这样就能在用户界面中产生“打字机效果”,而无需等待所有数据全部传输完毕。

    多模态输入

    许多现代模型不仅能接收文本信息,还能处理图像、音频、视频和文档等类型的数据。

    Genkit通过Part类来处理多模态输入。原本只是字符串形式的提示信息,在经过处理后会变成一个由多个部分组成的列表,其中每个部分可以是文本、媒体链接或原始数据。

    通过URL提供图像

    final response = await ai.generate(
      model: googleAI.gemini('gemini-2.5-flash'),
      prompt: [
        Part.media(url: 'https://example.com/product.jpg'),
        Part.text('这张图片展示的是哪种产品?如果能看到品牌名称,请也一并说明。'),
      ],
    );
    
    print(response.text);
    

    以原始字节形式提供图像

    当图像是在设备上拍摄的,或者是从文件系统中读取的,你可以将其转换为Base64编码的字节串,并明确指定其MIME类型:

    import 'dart:convert';
    import 'dart:io';
    
    final imageFile = File('/path/to/photo.jpg');
    final imageBytes = await imageFile.readAsBytes();
    final base64Image = base64Encode(imageBytes);
    
    final response = await ai.generate(
      model: googleAI.gemini('gemini-2.5-flash'),
      prompt: [
        Part.media(
          url: 'data:image/jpeg;base64,$base64Image',
        ),
        Part.text('请识别这个物品并详细描述它。'),
      ],
    );
    

    使用data: URL编码方式,可以直接将二进制图像数据嵌入到提示信息中,这种方式无需先将数据上传到任何存储服务。

    具有结构化输出的多模态输入

    多模态提示信息可以与结构化输出格式规范很好地结合使用:

    final response = await ai.generate(
      model: googleAI.gemini('gemini-2.5-flash'),
      prompt: [
        Part.media(url: 'data:image/jpeg;base64,$base64Image'),
        Part.text('请仔细分析这个物品。'),
      ],
      outputSchema: ProductScan.$schema,
    );
    
    final ProductScan? scan = response.output;
    

    该模型既能接收图像,也能接收文本指令,并且必须按照ProductScan JSON结构进行响应。这种多模态输入生成结构化输出的方式,正是我们后续将在本指南中构建的物品识别系统的核心机制。

    结构化输出

    结构化输出值得我们进一步深入探讨,因为Genkit是如何将模式要求传递给模型的,这一机制确实值得了解。

    当你将outputSchema传入ai.generate()函数时,Genkit会执行两步操作:首先,它会在提示信息中包含模式规范,指示模型生成符合指定结构的JSON响应;其次,在模型生成响应后,Genkit会解析该响应并验证其是否符合模式要求。如果输出结果不符合要求,Genkit可以选择重新尝试生成响应,或者抛出异常。

    正因为如此,每个属性上的@Field(description: '...')注释才如此重要。描述内容会被包含在发送给模型的模式规范中。如果没有描述,模型就不知道应该使用什么范围或格式;而如果有明确的描述,模型就能准确知道应该如何填写这些字段。

    实际操作建议是:编写字段描述时,要假设接收这些描述的人是完全不了解你的代码的开发者。因此,必须明确说明单位、取值范围、格式以及任何特定领域的含义。

    开发者用户界面

    开发者用户界面是一个随Genkit CLI一起提供的本地服务器Web应用程序。这一功能使得使用Genkit比直接通过API进行开发要方便得多,因此它值得专门用一个章节来介绍。

    启动开发者用户界面

    在安装了Genkit CLI之后,在你的项目目录中执行以下命令:

    genkit start -- dart run

    这条命令会同时启动你的Dart应用程序和开发者用户界面,且该界面会与正在运行的应用程序连接。终端会显示默认的URL地址,即http://localhost:4000

    对于Flutter应用程序来说,CLI还提供了专门的命令:

    genkit startflutter -- -d chrome

    这条命令会启动Genkit用户界面,在Chrome浏览器中运行你的Flutter应用程序,同时生成一个包含服务器配置信息的genkit.env文件,并将这些环境变量传递给Flutter运行时。所有这些操作都只需通过一条命令完成。

    开发者用户界面展示的内容

    左侧侧边栏列出了你的应用程序中定义的所有处理流程。点击某个流程名称,就可以打开该流程的详细信息页面。

    “运行”选项卡会以结构化形式显示流程的输入格式。您只需填写相应的字段,然后点击“运行”按钮即可。流程开始执行后,其输出结果会显示在响应面板中。对于流式处理流程,您会看到输出内容实时、分阶段地呈现出来。这样一来,您无需编写测试代码或使用curl工具,就能轻松测试这些流程。

    “追踪记录”选项卡会展示每次流程运行时的执行历史。每条追踪记录都呈树状结构显示:最顶层是整个流程,其中详细列出了每一次ai.generate()函数的调用信息、发送给模型的具体提示语、模型返回的响应内容、所使用的模型类型、令牌使用情况以及延迟时间等数据。对于那些需要多次调用模型的多步骤流程,每次调用都会在树结构中以单独的节点形式呈现出来。

    当流程产生意外的输出结果时,追踪记录就是非常有用的调试工具。您无需添加打印语句或重新运行流程,只需查看追踪记录中模型接收到的具体提示语,往往就能立即发现问题:可能是模板字符串被错误地替换了,也可能是某个变量为空,又或者字段描述误导了模型。修正相关问题后,重新运行流程并检查新的追踪结果即可。

    在Flutter环境中运行Genkit:三种架构模式

    Genkit Dart支持三种不同的方式,将AI逻辑集成到Flutter应用程序中。选择哪种方式取决于您的提示语的敏感性、AI逻辑的复杂程度以及您当前所处的开发阶段。

    模式1:完全客户端侧(仅用于原型设计)

    在这种模式下,所有的Genkit逻辑都在Flutter应用程序内部运行。Genkit实例是在Flutter代码中创建的,AI流程也是在那里定义的,而模型相关的API调用则是直接从设备端发起的。

    // 在您的Flutter应用程序中
    class AIService {
      late final Genkit _ai;
      late final dynamic _identificationFlow;
    
      AIService() {
        _ai = Genkit(plugins: [googleAI]);
        _identificationFlow = _ai.defineFlow(
          name: 'identifyItem',
          inputSchema: ScanInput.$schema,
          outputSchema: ItemResult.$schema,
          fn: (input, _) async {
            final response = await _ai.generate(
              model: googleAI.gemini('gemini-2.5-flash'),
              prompt: [
                 MediaPart(media: Media(url: 'data:image/jpeg;base64,${input.imageBase64}'),
                TextPart(text: '识别并描述这个物品。'),
              ],
              outputSchema: ItemResult.$schema,
            );
            return response.output!";
          },
        );
      }
    }
    

    这种架构在开发阶段非常方便,但绝对不适合用于生产环境。由于API密钥必须嵌入到应用程序中才能使其正常工作,而移动应用程序是可以被反编译的,因此任何有足够动机的人都可以从二进制文件中提取出这个密钥。

    如果您只是在自己的设备上进行原型设计,并且能够控制API密钥的使用,那么这种架构是可行的。但对于任何已经发布的应用程序来说,都应该采用下面介绍的基于服务器的架构模式。

    模式2:远程模型(混合架构)

    这种架构将模型调用分离到安全的服务器上,而将流程协调逻辑保留在Flutter客户端中。

    你需要部署一个Genkit Shelf后端服务来提供模型接口;Flutter应用则会定义指向这些接口的远程模型。虽然Flutter代码负责协调整个流程,但实际的模型API调用是在存储有密钥的服务器上进行的。

    在服务器端(使用Dart和Shelf框架):

    import 'package:genkit/genkit.dart';
    import 'package:genkit_google_genai/genkit_google_genai.dart';
    import 'package:genkit_shelf/genkit_shelf.dart';
    import 'package:shelf/router/shelf_router.dart';
    import 'package:shelf/shelf_io.dart' as io;
    
    void main() async {
      final ai = Genkit(plugins: [googleAI()]);
    
      final router = Router()
        ..all('/googleai/', serveModel(ai));
    
      await io.serve(router.call, '0.0.0.0', 8080);
    }
    

    在Flutter应用中:

    final ai = Genkit();  // 客户端无需配置插件
    
    final remoteGemini = ai.defineRemoteModel(
      name: 'remoteGemini',
      url: 'https://your-backend.com/googleai/gemini-2.5-flash',
    );
    
    final identificationFlow = aidefineFlow(
      name: 'identifyItem',
      inputSchema: ScanInput.$schema,
      outputSchema: ItemResult.$schema,
      fn: (input, _) async {
        final response = await ai.generate(
          model: remoteGemini,
          prompt: [
             MediaPart(media: Media(url: 'data:image/jpeg;base64,${input.imageBase64}')),
            TextPart(text:'识别这个物品。'),
          ],
          outputSchema: ItemResult.$schema,
        );
        return response.output!;
      },
    );
    

    Flutter应用根本不会接触到Gemini API的密钥,它所知道的就是模型接口的URL。所有的模型调用都是由服务器来处理的,服务器会负责代理这些请求。

    模式3:服务器端处理流程(最安全的方案)

    这种架构被推荐用于生产环境中的应用。整个AI处理流程,包括提示信息、模型调用、工具使用以及输出数据的结构,都存储在服务器上。Flutter应用只是一个轻量级的客户端,它负责发送请求并接收结构化响应。

    在服务器端:

    import 'package:genkit/genkit.dart';
    import 'package:genkit_google_genai/genkit_google_genai.dart';
    import 'package:genkit_shelf/genkit_shelf.dart';
    import 'package:shelf/router/shelf_router.dart';
    import 'package:shelf/shelf_io.dart' as io;
    
    void main() async {
      final ai = Genkit(plugins: [googleAI()]);
    
      final identificationFlow = ai.defineFlow(
        name: 'identifyItem',
        inputSchema: ScanInput.$schema,
        outputSchema: ItemResult.$schema,
        fn: (input, _) async {
          final response = await ai.generate(
            model: googleAI.gemini('gemini-2.5-flash'),
            prompt: [
              MediaPart(media: Media(url: 'data:image/jpeg;base64,${input.imageBase64}')),
              TextPart(text:'请识别并详细描述这个物品。'),
            ],
            outputSchema: ItemResult.$schema,
          );
          return response.output!;
        },
      );
    
      final router = Router()
        ..post('/identifyItem', shelfHandler(identificationFlow));
    
      await io.serve(router.call, '0.0.0.0', 8080);
    }
    

    在Flutter应用程序中(使用共享模式包):

    import 'package:http/http.dart' as http;
    import 'dart:convert';
    import 'shared_schemas.dart';  // 客户端和服务器之间共享的模式
    
    class IdentificationService {
      static Future identifyItem(String base64Image) async {
        final request = ScanInput(imageBase64: base64Image);
    
        final httpResponse = await http.post(
          Uri.parse('https://your-backend.com/identifyItem'),
          headers: {'Content-Type': 'application/json'},
          body: jsonEncode({'data': request.toJson()}),
        );
    
        final body = jsonDecode(httpResponse.body);
        return ItemResult.fromJson(body['result']);
      }
    }
    

    由于服务器和Flutter客户端都使用Dart语言编写,因此你可以将ScanInputItemResult这些类放在一个被双方共同引用的共享包中。当模式发生变更时,你只需在其中一个地方进行更新,编译器就会自动检查两边是否存在不匹配的地方。

    部署

    Genkit Dart的一个实际优势在于,Dart服务器应用程序拥有多种成熟的部署方案。

    Shelf框架

    genkit_shelf包将Genkit流程与Shelf HTTP服务器库集成在一起。shelfHandler()函数可以将Genkit流程转换为Shelf请求处理程序。你只需将其添加到路由器中,然后启动Shelf服务器,整个部署过程就完成了。

    import 'package:genkit_shelf/genkit_shelf.dart';
    import 'package:shelf.router/shelf_router.dart';
    import 'package:shelf/shelf_io.dart' as io;
    
    final router = Router()
      ..post('/api/identifyItem', shelfHandler(identificationFlow))
      ..post('/api/generateReport', shelfHandler(reportFlow));
    
    await io.serve(router.call, '0.0.0.0', 8080);
    

    每个Genkit流程都会对应一个POST接口。客户端会发送{"data": {...}}这样的请求数据,然后收到格式为JSON的响应结果。

    Cloud Run部署

    对于Genkit Dart后端来说,Google Cloud Run是最便捷的部署方案。你可以使用Dockerfile将Shelf应用程序容器化,然后将生成的镜像推送至Google Container Registry或Artifact Registry,最后在Cloud Run上部署该应用。Cloud Run会负责处理扩展性、HTTPS连接以及区域分发等工作。

    FROM dart:stable AS build
    WORKDIR /app
    COPY pubspec.* ./
    RUN dart pub get
    COPY . .
    RUN dart compile exe bin/server.dart -o bin/server
    
    FROM scratch
    COPY --from=build /runtime/ /
    COPY --from=build /app/bin/server /app/bin/server
    EXPOSE 8080
    CMD ["/app/bin/server"]
    

    Firebase部署

    使用Firebase插件,你可以将Genkit流程作为Firebase Cloud Functions来部署。如果你的应用程序已经使用了Firebase进行身份验证、数据存储等功能,那么这种部署方式会更加方便,因为AI处理流程和其他功能会共享同一个项目设置,从而利用相同的IAM权限管理机制。

    AWS Lambda与Azure Functions

    该框架还提供了关于如何在AWS Lambda或Azure Functions中部署Genkit Dart后端的文档,因此根据您所在组织的基础设施现状,您可以选择将这些后端托管在这些主流云平台上。

    可观测性与追踪功能

    在Genkit中,每次流程执行都会生成一条追踪记录。这条追踪记录会详细记录执行过程中发生的所有事件:接收到的输入数据、调用的各个模型、每次调用的具体参数、返回的结果、令牌使用情况、每一步的执行延迟以及最终的输出结果。

    在开发环境中,这些追踪记录可以在开发者界面的“追踪”选项卡中查看。在生产环境中,您可以使用genkit_google_cloud插件将这些数据导出到Google Cloud Operations(原名为Stackdriver)中,或者导入到任何支持OpenTelemetry的后端系统中。

    import 'package:genkit/google_cloud/genkit_google_cloud.dart';
    
    final ai = Genkit(plugins: [
      googleAI(),
      googleCloud(),  // 将追踪记录和指标数据导出到Google Cloud
    ]);
    

    通过这种配置,每次流程执行都会将其追踪数据发送到Google Cloud。您可以使用Cloud Trace来查看流程性能的变化情况,找出瓶颈所在,并将人工智能模型的行为与应用层面的指标数据进行关联分析。

    对于那些需要处理真实用户请求的生产环境应用程序来说,这类可观测性功能是必不可少的。只有通过这些功能,才能及时发现模型变更对系统性能产生的负面影响。

    构建实时物品识别应用

    在开始项目之前,请确保您已经具备了以下所需条件。

    Dart SDK

    您需要使用Dart SDK 3.10.0或更高版本。如果您已经安装了Flutter,可以通过以下命令查看您的Dart版本:

    dart --version
    

    如果版本低于3.10.0,请先升级Flutter:

    flutter upgrade
    

    由于Flutter自带了Dart SDK,因此升级Flutter的同时也会自动更新Dart版本。

    Flutter SDK

    您需要使用Flutter 3.22.0或更高版本。可以通过以下命令验证版本:

    flutter --version
    

    该项目使用了camera插件来进行图像捕获。该插件至少需要Flutter 3.x版本,并且可以在Android API 21及以上版本、iOS 11及以上版本上正常使用。

    Genkit CLI

    curl -sL cli.genkit.dev | bash
    

    安装完成后,请重新启动终端并验证是否成功安装了Genkit CLI:

    genkit --version
    

    Gemini API密钥

    请访问aistudio.google.com/apikey,使用您的Google账户登录,然后生成一个新的API密钥。请将这个密钥保存在安全的地方。生成API密钥不需要使用信用卡,Gemini API的免费 tier就足以满足您构建和测试应用程序的需求。

    将密钥设置为环境变量:

    export GEMINI_API_KEY=你的实际密钥

    为确保这些设置能在不同的终端会话中持续生效,请将该行添加到你的Shell配置文件中(例如~/.bashrc~/.zshrc等)。

    先决知识

    本教程假定你已经熟悉Dart的async/await语法,至少开发过一个Flutter应用程序,并且理解了Widget树的概念。本教程不要求你具备任何关于AI API或大语言模型的先验经验。我们会在需要时逐步介绍与AI相关的概念。

    我们正在开发的这个应用程序名为LensID。用户打开应用程序,将摄像头对准任意物体,点击拍摄按钮后,就会收到关于该物体的结构化分析结果:物品名称、状况、使用类型以及置信度评分。

    应用程序的图片

    Genkit Dart在Flutter环境中提供了完整的技术栈:能够捕获设备输入数据,通过类型化的数据流将多模态信息发送到模型中,并在用户界面中呈现结构化、类型化的输出结果。

    对于本指南而言,由于这只是一个学习练习,因此AI逻辑完全运行在客户端,以保持项目的独立性。在实际发布的应用程序中,你需要按照前面提到的第3种方式将相关数据处理流程转移到服务器端。

    项目结构

    lens_id/
      lib/
        main.dart
        screens/
          camera_screen.dart
          result_screen.dart
          splash_screen.dart
        services/
          identification_service.dart
        models/
          scan_models.dart
          scan_models.g.dart
        widgets/
          result_card.dart
      pubspec.yaml
    

    步骤1:创建Flutter项目

    flutter create lens_id
    cd lens_id
    

    步骤2:添加依赖项

    打开pubspec.yaml文件,更新dependenciesdev_dependencies部分的内容:

    dependencies:
      flutter:
        sdk: flutter
      genkit: ^0.12.1
      genkit_google_genai: ^0.2.4
      camera: ^0.12.0+1
      permission_handler: ^12.0.1
      googlefonts: ^8.0.2
      image_picker: ^1.2.1
      
    
    devDependencies:
      flutter_test:
        sdk: flutter
      buildrunner: ^2.13.1
      schemantic: 0.1.1
    

    运行安装命令:

    flutter pub get
    

    步骤3:配置平台权限

    Android(android/app/src/main/AndroidManifest.xml):

    请将这些权限添加到 `` 标签内,位于 `` 标签之前:

    <!-- 权限设置 -->
    <uses-permission android:name="android.permission.CAMERA" />>
    <uses-permission android:name="android.permission.INTERNET" />>
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    
    <!-- 媒体/存储权限设置(Android 13及更高版本支持细分权限) -->
    <uses-permission android:name="android.permission.READ_MEDIA IMAGES" />>
    <uses-permission android:name="android.permission.READ(Media_VIDEO)" />
    
    <!-- 对于Android 12及更低版本,仍使用传统的存储权限设置 -->
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />>
    <>uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" />
    
    
    <!-- 相机硬件功能相关设置(此选项非必需,因此应用可在所有设备上安装) -->
    <>uses-feature android:name="android.hardware.camera" android:required="true" />>
    <>uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
    

    同时,请确保文件`android/app/build.gradle`中的`minSdkVersion`值至少为21:

    defaultConfig {
    minSdkVersion 21
    }

    iOS(`ios/Runner/Info.plist`文件):

    需要在``标签内添加以下键值对:

    <key>>NSCameraUsageDescription<>/key>

    < string>>镜头ID需要访问相机功能,以便扫描并识别物品。

    NSPhotoLibraryUsageDescription<>/key>

    < string>>镜头ID需要访问您的照片库,以便上传图片用于识别。

    步骤4:定义数据结构

    创建文件`lib/models/scan_models.dart`:

    import 'package:schemantic/schemantic.dart';

    part 'scan_models.g.dart';

    /// 输入参数:需要分析的图像
    @Schema()
    abstract class $ScanRequest {
    @Field(description: '用于识别的物品的Base64编码JPEG图像')
    String get imageBase64;
    }

    /// 适用于简单用户界面的最小输出数据结构
    @Schema()
    abstract class $ItemIdentification {
    /// 仅包含物品名称
    @Field(description: '物品的名称')
    String get itemName;

    /// 简洁描述物品的状态
    @Field(description: '用简短语句描述物品的状态')
    String get condition;

    /// 说明物品的用途
    @Field(description: '用一句话说明物品的用途')
    String get usage;

    /// 可选的识别置信度(仅供参考,无需过度使用)
    @Field(
    description:
    '表示识别准确度的置信度分数,范围为0%到100%'
    )
    double get confidenceScore;
    }

    这个文件定义了镜头ID识别流程所需的数据结构。两个`@Schema()`抽象类用于规定输入数据的内容以及输出结果的结构。

    $ScanRequest代表输入数据。它告诉系统,模型所需要的仅仅是一张经过base64编码的图像,没有任何额外的元数据或复杂的结构,只有图像本身。

    $ItemIdentification代表输出结果。它定义了AI必须返回的具体结构,并要求响应内容尽可能简洁。模型被限制只能生成四个字段:itemName、condition、usage和confidenceScore,而不会生成详细的分析结果。

    每个@Field()注释都包含相应的描述,这些描述实际上是通过Genkit直接发送给模型的指令,它们指导模型如何填充各个字段,从而确保输出结果的一致性。

    itemName字段要求模型返回一个简洁且易于识别的名称,而不是冗长的描述;condition字段确保响应内容简短明了,例如“新”或“二手”;usage字段限制输出为一句简洁的句子,说明该物品的用途;confidenceScore字段则规定了数值的范围和格式,使模型能够返回一致性的数值结果。

    由于数据结构非常简单,描述也十分精确,模型几乎没有机会生成不必要的信息。这样一来,响应结果就会保持清晰、可预测,并且与简单的用户界面相匹配。

    part 'scan_models.g.dart'指令会在构建工具生成代码文件后,将其与该文件连接起来。

    现在运行代码生成器吧:

    dart run build_runner build --delete-conflicting-outputs

    这样就会生成lib/models/scan_models.g.dart文件,其中包含了具体的ScanRequestItemIdentification类,包括构造函数、JSON序列化方法,以及Genkit所使用的$schema静态属性。

    步骤5:创建识别服务

    创建lib/services/identification_service.dart文件:

    import 'dart:convert';
    import 'dart:io';
    
    import 'package:genkit/genkit.dart';
    import 'package:genkit.google_genai/genkit_google_genai.dart';
    
    import '../models/scan_models.dart';
    
    /// 该类封装了将图像发送到Gemini并返回结构化[ItemIdentification]结果的流程。
    class IdentificationService {
      late final Genkit _ai;
      late final Future Function(ScanRequest) _identifyFlow;
    
      IdentificationService() {
        // 读取在构建时通过--dart-define参数注入的API密钥;
        // 如果使用`dart run`或`genkit start`运行,则会回退到环境变量GEMINI_API_KEY。
        const dartDefineKey = String.fromEnvironment('GEMINI_API_KEY');
        final apiKey = dartDefineKey.isNotEmpty
            ? dartDefineKey
            : Platform.environment['GEMINI_API_KEY'];
    
        _ai = Genkit(
          plugins: [
            googleAI(apiKey: apiKey),
          ],
        );
    
        // 一次性定义这个流程,之后每次扫描都会重用它。
        _identifyFlow = _ai.defineFlow(
          name: 'identifyItemFlow',
          inputSchema: ScanRequest.$schema,
          outputSchema: ItemIdentification.$schema,
          fn: _runIdentification,
        ).call;
      }
    
      /// 核心逻辑:构建多模态提示信息,并调用Gemini 2.5 Flash进行识别。
      Future _runIdentification(
        ScanRequest request,
        // ignore: 避免使用动态调用
        dynamic context,
      ) async {
        // 直接将图像作为数据URL嵌入到请求中,无需存储或上传。
        final imagePart = MediaPart(
          media: Media(
            url: 'data:image/jpeg;base64,${request.imageBase64}',
            contentType: 'image/jpeg',
          ),
        );
    
        // 文本部分用于指定模型的角色并给出明确的指令;
        // 数据结构中的字段描述进一步强化了这些指令。
        final instructionPart = TextPart(
          text: '您是一个产品识别助手。请仔细分析图片中的物品,并仅根据可见的信息提供准确的识别结果。如果品牌名称无法辨认,请不要随意猜测。'
        );
    
        // 调用Genkit模型进行识别
        final response = await _ai.generate(
          model: googleAI.gemini('gemini-2.5-flash'),
          messages: [
            Message(
              role: Role.user,
              content: [imagePart, instructionPart],
            ),
          ],
          outputSchema: ItemIdentification.$schema,
        );
    
        if (response.output == null) {
          throw Exception(
            'Gemini没有返回有效的结构化响应结果。请使用清晰、光照良好的图像重新尝试。'
          );
        }
    
        return response.output!;
      }
    
      /// 公共入口方法:接受一个图像文件作为输入,返回相应的识别结果。
      Future identifyFromFile(File imageFile) async {
        final bytes = await imageFile.readAsBytes();
        final base64Image = base64Encode(bytes);
        return _identifyFlow(ScanRequest(imageBase64: base64Image));
      }
    }
    

    该文件定义了用于将您的Flutter应用程序与AI模型连接起来的服务。它负责接收图像,将其发送给模型,并返回符合您所定义的数据结构的响应结果。

    IdentificationService类会创建一个Genkit实例,并准备一套可重复使用的流程来识别对象。在初始化过程中,它会从通过--dart-define选项在构建时指定的值中,或从环境中获取API密钥。这样的设计使得该服务既适用于本地开发,也适用于生产环境。

    _identifyFlow流程仅需要被定义一次,即可通过defineFlow方法完成配置。该流程会将输入数据结构与输出数据结构关联起来,并调用_runIdentification函数来处理请求。这样一来,所有通过这一流程处理的请求都会严格遵守您所定义的数据结构,从而保证系统的稳定性和可预测性。

    _runIdentification方法包含了核心逻辑:它会从请求中提取base64编码的图像,并将其直接嵌入到数据URL中,这样就无需将图像上传到外部存储空间了。

    随后,该图像会与一条文本指令结合在一起,这条指令会告诉AI模型应该如何进行处理。这些指令简洁明了,能够引导模型仅分析可见的部分,避免做出不必要的假设。

    最终,请求会通过Genkit的generate方法被发送到Gemini模型。模型会同时处理图像和指令,然后返回符合ItemIdentification数据结构的响应结果。由于输出数据结构是严格规定的,因此系统会自动将响应结果解析为对应的类型化对象。

    系统中还包含了安全检查机制,用于确保模型确实返回了有效的、结构规范的响应结果。如果没有满足这些要求,系统会抛出异常,并附带清晰的错误信息,以便应用程序能够妥善处理这种错误情况。

    identifyFromFile方法是您的UI组件可以使用的公共入口点。它接收一个图像文件,将其转换为base64编码格式,然后传递给相应的处理流程。最终返回的结果已经是结构化的数据,可以直接在结果展示界面上显示。

    总的来说,这项服务充当了您的UI界面与AI模型之间的桥梁,确保图像能够被正确处理,同时保证响应结果的结构清晰、规范一致,符合您所追求的极简设计理念。

    步骤6:构建相机界面

    创建文件lib/screens/camera_screen.dart

    import 'dart:io';

    import 'package:camera/camera.dart';
    import 'package:flutter/material.dart';
    import 'package:imagepicker/image_picker.dart';
    import 'package:permission_handler/permission_handler.dart';

    import '../services/identification_service.dart';
    import 'result_screen.dart';

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

    @override
    State createState() => _CameraScreenState();
    }

    class _CameraScreenState extends State
    with WidgetsBindingObserver {
    CameraController? _controller;
    List _cameras = [];
    bool _isCameraReady = false;
    bool _isCapturing = false;
    String? _initError;

    final _service = IdentificationService();

    @override
    void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
    _initCamera();
    }

    @override
    void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    _controller?.dispose();
    super.dispose();
    }

    // 当应用程序进入后台时,释放并重新获取摄像头资源。
    @override
    void didChangeAppLifecycleState(AppLifecycleState state) {
    if (state == AppLifecycleState.inactive) {
    _controller?.dispose();
    if (mounted) setState(() => _isCameraReady = false);
    } else if (state == AppLifecycleState.resumed && _cameras.isNotEmpty) {
    _setupController(_cameras.first);
    }
    }

    Future _initCamera() async {
    final status = await Permission.camera.request();
    if (!status.isGranted) {
    if (mounted) {
    setState(() => _initError = '需要摄像头权限才能进行识别操作。\n您仍然可以上传图片。');
    }
    return;
    }

    try {
    _cameras = await availableCameras();
    } catch (e) {
    if (mounted) setState(() => _initError = '无法获取摄像头信息:$e\n您仍然可以上传图片。');
    return;
    }

    if (_cameras.isEmpty) {
    if (mounted) setState(() => _initError = '设备上没有找到摄像头。\n您仍然可以上传图片。');
    return;
    }

    await _setupController(_cameras.first);
    }

    Future _setupController(CameraDescription camera) async {
    await _controller?.dispose();

    final controller = CameraController(
    camera,
    ResolutionPreset.high,
    enableAudio: false,
    imageFormatGroup: ImageFormatGroup.jpeg,
    );

    try {
    await controller.initialize();
    _controller = controller;
    if (mounted) setState(() => _isCameraReady = true);
    } catch (e) {
    if (mounted) setState(() => _initError = '摄像头初始化失败:$e');
    }
    }

    Future _captureAndIdentify() async {
    if (_isCapturing || !_isCameraReady) return;
    if (_controller == null || !_controller!.value.isInitialized) return;

    setState(() => _isCapturing = true);

    try {
    final xFile = await _controller!.takePicture();
    final imageFile = File(xFile.path);

    if (!mounted) return;
    _showLoadingDialog();

    final result = await _service.identifyFromFile(imageFile);

    if (!mounted) return;
    Navigator.of(context).pop(); // 关闭加载提示框

    await Navigator.of(context).push(
    MaterialPageRoute(
    builder: (_) => ResultScreen(
    imageFile: imageFile,
    identification: result,
    ),
    ),
    );
    } catch (error) {
    if (mounted && Navigator.of(context).canPop()) {
    Navigator.of(context).pop();
    }
    if (mounted) {
    ScaffoldMessenger.of(context).showSnackBar(
    SnackBar(
    content: Text('识别失败:$error'),
    backgroundColor: Colors.red.shade700,
    behavior: SnackBarBehavior.floating,
    ),
    );
    }
    } finally {
    if (mounted) setState(() => _isCapturing = false);
    }
    }

    Future _pickImage() async {
    if (_isCapturing) return;

    final picker = ImagePicker();
    final xFile = await picker.pickImage(source: ImageSource.gallery);
    if (xFile == null) return;

    setState(() => _isCapturing = true);

    try {
    final imageFile = File(xFile.path);

    if (!mounted) return;
    _showLoadingDialog();

    final result = await _service.identifyFromFile(imageFile);

    if (!mounted) return;
    Navigator.of(context).pop(); // 关闭加载提示框

    await Navigator.of(context).push(
    MaterialPageRoute(
    builder: (_) => ResultScreen(
    imageFile: imageFile,
    identification: result,
    ),
    ),
    );
    } catch (error) {
    if (mounted && Navigator.of(context).canPop()) {
    Navigator.of(context).pop();
    }
    if (mounted) {
    ScaffoldMessenger.of(context).showSnackBar(
    SnackBar(
    content: Text('识别失败:$error'),
    backgroundColor: Colors.red.shade700,
    behavior: SnackBarBehavior.floating,
    ),
    );
    }
    } finally {
    if (mounted) setState(() => _isCapturing = false);
    }
    }

    void _showLoadingDialog() {
    showDialog(
    context: context,
    barrierDismissible: false,
    builder: (_) => const Center(
    child: Card(
    margin: EdgeInsets_symmetric(horizontal: 48),
    child: Padding(
    padding: EdgeInsets_symmetrichorizontal: 32, vertical: 28),
    child: Column(
    mainAxisSize: MainAxisSize.min,
    children: [
    CircularProgressIndicator(),
    SizedBox(height: 20),
    Text(
    '正在识别物品…',
    style: TextStyle(fontSize: 16),
    ),
    SizedBox(height: 6),
    Text(
    '由 Gemini 提供支持',
    style:TextStyle fontSize: 12, color: Colors.grey),
    ),
    ],
    ),
    ),
    ),
    ),
    );
    }

    @override
    Widget build(BuildContext context) {
    return Scaffold(
    backgroundColor: Colors.black,
    body: Stack(
    fit: StackFit.expand,
    children: [
    // 摄像头预览/错误提示/加载状态显示
    if (_initError != null)
    _ErrorPlaceholder(message: _initError!)
    else if (_isCameraReady && _controller != null)
    CameraPreview(_controller!)
    else
    const _LoadingPlaceholder(),
    ,

    // 带有扫描线的取景器边框显示
    if (_isCameraReady) const _ViewfinderCorners(),
    ,

    // 底部控制按钮
    Positioned(
    bottom: 40,
    left: 0,
    right: 0,
    child: Column(
    mainAxisSize: MainAxisSize.min,
    children: [
    _CaptureButton(
    isCapturing: _isCapturing,
    enabled: _isCameraReady && !_isCapturing,
    onTap: _captureAndIdentify,
    ),
    const SizedBox(height: 16),
    GestureDetector(
    onTap: _pickImage,
    child: const Row(
    mainAxisAlignment: MainAxisAlignment.center,
    children: [
    IconIcons.upload_rounded, color: Colors.white60, size: 16),
    SizedBox(width: 4),
    Text(
    '上传',
    style: TextStyle(
    color: Colors.white60,
    fontSize: 12,
    fontWeight: FontWeight.w700,
    letterSpacing: 1.0,
    ),
    ),
    ],
    ),
    ),
    ],
    ),
    ),
    ],
    ),
    );
    }
    }

    // 辅助组件

    class _LoadingPlaceholder extends StatelessWidget {
    const _LoadingPlaceholder();

    @override
    Widget build(BuildContext context) => const Column(
    mainAxisAlignment: MainAxisAlignment.center,
    children: [
    CircularProgressIndicator(color: Colors.white54),
    SizedBox(height: 16),
    Text('正在启动摄像头…',
    style: TextStyle(color: Colors.white54, fontSize: 14)),
    ],
    );
    }

    class _ErrorPlaceholder extends StatelessWidget {
    final String message;
    const _ErrorPlaceholder({required this.message});

    @override
    Widget build(BuildContext context) => Padding(
    padding: const EdgeInsets.all(32),
    child: Column(
    mainAxisAlignment: MainAxisAlignment.center,
    children: [
    const IconIcons.camera_alt_outlined, color: Colors.white38, size: 64),
    const SizedBox(height: 20),
    Text(
    message,
    textAlign: TextAlign.center,
    style: const TextStyle(color: Colors.white70, fontSize: 15),
    ),
    const SizedBox(height: 24),
    OutlinedButton(
    style: OutlinedButton.styleFrom(
    foregroundColor: Colors.white,
    side: const BorderSide(color: Colors.white38),
    ),
    onPressed: () => openAppSettings(),
    child: const Text('打开设置'),
    ),
    ],
    ),
    );
    }

    class _ViewfinderCorners extends StatelessWidget {
    const _ViewfinderCorners();

    @override
    Widget build(BuildContext context) {
    const size = 48.0;
    const thickness = 2.0;
    const color = Color(0xFFD67123);

    Widget corner({required bool top, required bool left}) {
    return Positioned(
    top: top ? 0 : null,
    bottom: top ? null : 0,
    left: left ? 0 : null,
    right: left ? null : 0,
    child: SizedBox(
    width: size,
    height: size,
    child: CustomPaint(
    painter: _CornerPainter(
    top: top, left: left, color: color, thickness: thickness),
    ),
    ),
    );
    }

    final screenSize = MediaQuery.of(context).size;
    final boxSize = screenSize.width * 0.75;
    final offsetX = (screenSize.width - boxSize) / 2;
    final offsetY = (screenSize.height - boxSize) / 2 - 40;

    return Positioned(
    left: offsetX,
    top: offsetY,
    width: boxSize,
    height: boxSize,
    child: Stack(
    clipBehavior: Clip.none,
    children: [
    corner(top: true, left: true),
    corner(top: true, left: false),
    corner(top: false, left: true),
    corner(top: false, left: false),
    // 扫描线
    const Positioned.fill(
    child: _ScannerLine(),
    ),
    ],
    ),
    );
    }
    }

    class _CornerPainter extends CustomPainter {
    final bool top;
    final bool left;
    final Color color;
    final double thickness;

    const _CornerPainter({
    required this.top,
    required this.left,
    required this.color,
    required this.thickness,
    });

    @override
    void paint(Canvas canvas, Size size) {
    final paint = Paint()
    ..color = color
    ..strokeWidth = thickness
    ..style = PaintingStyle.stroke
    ..strokeCap = StrokeCap.square;

    final path = Path();
    final h = size.height;
    final w = size.width;

    if (top && left) {
    path.moveTo(0, h);
    path.lineTo(0, 0);
    path.lineTo(w, 0);
    } else if (top && !left) {
    path.moveTo(0, 0);
    path.lineTo(w, 0);
    path.lineTo(w, h);
    } else if (!top && left) {
    path.moveTo(0, 0);
    path.lineTo(0, h);
    path.lineTo(w, h);
    } else {
    path.moveTo(0, h);
    path.lineTo(w, h);
    path.lineTo(w, 0);
    }

    canvas.drawPath(path, paint);
    }

    @override
    bool shouldRepaint(_CornerPainter old) => false;
    }

    class _ScannerLine extends StatefulWidget {
    const _ScannerLine();

    @override
    State<_ScannerLine> createState() => _ScannerLineState();
    }

    class _ScannerLineState extends State<_ScannerLine>
    with SingleTickerProviderStateMixin {
    late AnimationController _controller;

    @override
    void initState() {
    super.initState();
    _controller = AnimationController(
    vsync: this,
    duration: const Duration(seconds: 2),
    )..repeat(reverse: true);
    }

    @override
    void dispose() {
    _controller.dispose();
    superdispose();
    }

    @override
    Widget build(BuildContext context) {
    return AnimatedBuilder(
    animation: _controller,
    builder: (context, child) {
    return Align(
    alignment: Alignment(0, -1.0 + (_controller.value * 2.0)),
    child: Container(
    height: 2,
    width: doubleinfinity,
    decoration: BoxDecoration(
    color: const Color(0xFFD67123),
    shadows: [
    BoxShadow(
    color: const Color(0xFFD67123).withAlpha(120),
    blurRadius: 10,
    spreadRadius: 2,
    ),
    ],
    ),
    ),
    );
    },
    );
    }
    }

    class _CaptureButton extends StatelessWidget {
    final bool isCapturing;
    final bool enabled;
    final VoidCallback onTap;

    const _CaptureButton({
    required this.isCapturing,
    required this.enabled,
    required this.onTap,
    });

    @override
    Widget build(BuildContext context) {
    return GestureDetector(
    onTap: enabled ? onTap : null,
    child: Container(
    width: 80,
    height: 80,
    decoration: BoxDecoration(
    shape: BoxShape.circle,
    color: Colors.transparent,
    border: Border.all(
    color: Colors.white.withAlpha(150),
    width: 3,
    ),
    ),
    child: Center(
    child: AnimatedContainer(
    duration: const Duration(milliseconds: 150),
    width: isCapturing ? 40 : 64,
    height: isCapturing ? 40 : 64,
    decoration: const BoxDecoration(
    shape: BoxShape.circle,
    color: Color(0xFFBA2226),
    ),
    child: isCapturing
    ? const Center(
    child: SizedBox(
    width: 20,
    height: 20,
    child: CircularProgressIndicator(
    strokeWidth: 2.0,
    color: Colors.white,
    ),
    ),
    )
    : null,
    ),
    ),
    ),
    );
    }
    }

    这个界面负责处理相机的整个生命周期。`WidgetsBindingObserver`混合组件使得部件能够响应应用程序的生命周期事件,因此当应用程序进入后台时,相机能够被正确释放;而当应用程序重新启动时,相机又能被重新初始化。这样就可以避免在Android系统中出现相机资源冲突的问题。

    `_initializeCamera()`方法会在尝试访问相机之前,通过`permission_handler`来请求权限。在iOS系统中,如果没有获得权限就直接尝试访问相机会导致系统崩溃,而且这种崩溃是无法恢复的;而在Android系统中,虽然会失败,但不会产生明显的异常提示。明确地请求权限,并对可能出现的错误进行处理,能够为用户提供更加专业的使用体验。

    `CameraController`在初始化时会被设置为`ResolutionPreset.High`和`ImageFormatGroup.jpeg`。高分辨率可以让模型在识别过程中获得更多的细节信息;而选择JPEG格式,是因为在服务中,模型接收到的图像数据就是采用`data:image/jpeg;base64, ...`这种URL格式传递的。

    `_captureAndIdentify()`方法会拍摄图片、显示加载提示界面、调用服务接口、跳转到结果展示页面,并处理可能出现的错误。通过`try / catch / finally`结构,可以确保无论操作是否成功,或者是否出现了异常,`_isCapturing`变量的值都会被重置为`false`。

    步骤7:构建结果展示页面

    创建`lib/screens/result_screen.dart`文件:

    import 'dart:io';
    
    import 'package:flutter/material.dart';
    import 'package:googlefonts/google_fonts.dart';
    
    import '../models/scan_models.dart';
    
    class ResultScreen extends StatelessWidget {
      final File imageFile;
      final ItemIdentification identification;
    
      const ResultScreen({
        super.key,
        required this.imageFile,
        required this.identification,
      });
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          backgroundColor: Colors.white,
          body: SafeArea(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: [
                Expanded(
                  child: SingleChildScrollView(
                    child: Column(
                      CrosbyAlign: CrossAxisAlignment.start,
                      children: [
                        // 顶部图片
                        Padding(
                          padding: const EdgeInsets.all(16.0),
                          child: AspectRatio(
                            aspectRatio: 1.0,
                            child: ClipRRect(
                              borderRadius: BorderRadius.zero,
                              child: Image.file(
                                imageFile,
                                fit: BoxFit_cover,
                              ),
                            ),
                          ),
                        ),
    
                        Padding(
                          padding: const EdgeInsets.symmetric(horizontal: 24.0),
                          child: Column(
                            CrosbyAlign: CrossAxisAlignment.start,
                            children: [
                              const SizedBox(height: 8),
                              // 字幕
                              Text(
                                '识别结果:${identification.itemName}`,
                                style: GoogleFonts.rajdhani(
                                  color: const Color(0xFFDA292E),
                                  fontSize: 10,
                                  fontWeight: FontWeight.w900,
                                  letterSpacing: 1.5,
                                ),
                              ),
                              const SizedBox(height: 4),
    
                              // 主标题
                              Text(
                                identification.itemName.toUpperCase(),
                                style: GoogleFonts.bebasNeue(
                                  color: Colors.black,
                                  fontSize: 32,
                                  fontWeight: FontWeight.w900,
                                  fontStyle: FontStyle.italic,
                                  height: 1.0,
                                  letterSpacing: -1.0,
                                ),
                              ),
                              const SizedBox(height: 40),
    
                              // 条件与使用类型
                              Row(
                                CrosbyAlign: CrossAxisAlignment.start,
                                children: [
                                  Expanded(
                                    child: Column(
                                      CrosbyAlign: CrossAxisAlignment.start,
                                      children: [
                                        Text(
                                          '条件',
                                          style: GoogleFonts.rajdhani(
                                            color: Colors.grey,
                                            fontSize: 10,
                                            fontWeight: FontWeight.w800,
                                            letterSpacing: 1.0,
                                          ),
                                        ),
                                        const SizedBox(height: 6),
                                        Text(
                                          identification(condition.toUpperCase(),
                                          style: Google Fonts.rajdhani(
                                            color: Colors.black,
                                            fontSize: 13,
                                           fontWeight: FontWeight.w900,
                                            height: 1.2,
                                          ),
                                        ),
                                      ],
                                    ),
                                  ),
                                  const SizedBox(width: 16),
                                  Expanded(
                                    child: Column(
                                      CrosbyAlign: CrossAxisAlignment.start,
                                      children: [
                                        Text(
                                          '使用类型',
                                          style: GoogleFonts.rajdhani(
                                            color: Colors.grey,
                                            fontSize: 10,
                                            fontWeight: FontWeight.w800,
                                            letterSpacing: 1.0,
                                          ),
                                        ),
                                        const SizedBox(height: 6),
                                        Text(
                                          identification.usage.toUpperCase(),
                                          style: Google Fonts.rajdhani(
                                            color: Colors.black,
                                            fontSize: 13,
                                           fontWeight: FontWeight.w900,
                                            height: 1.2,
                                          ),
                                        ),
                                      ],
                                    ),
                                  ),
                                ],
                              ),
                              const SizedBox(height: 32),
    
                              // 置信度评分
                              Text(
                                '置信度评分:${(identification.confidenceScore * 100).toStringAsFixed(2)}%",
                                style: GoogleFonts.bebasNeue(
                                      color: Colors.black,
                                      fontSize: 36,
                                      fontWeight: FontWeight.w900,
                                      letterSpacing: -1.0,
                                    ),
                                  ),
                              const SizedBox(height: 2),
                              Row(
                                CrosbyAlign: CrossAxisAlignment.center,
                                children: [
                                  Text(
                                    '${(identification.confidenceScore * 100).toStringAsFixed(2)}%',
                                    style: GoogleFonts.bebasNeue(
                                      color: Colors.black,
                                      fontSize: 36,
                                      fontWeight: FontWeight.w900,
                                      letterSpacing: -1.0,
                                    ),
                                  ),
                                  const SizedBox(width: 12),
                                  Expanded(
                                    child: Container(
                                      height: 2,
                                      color: const Color(0xFFDA292E),
                                    ),
                                  ),
                                ],
                              ),
                              const SizedBox(height: 24),
                            ],
                          ),
                        ),
                      ],
                    ),
                  ),
                ),
                
                // 底部按钮
                Padding(
                  padding: const EdgeInsets 生命周期(24, 8, 24, 24),
                  child: SizedBox(
                    width: doubleinfinity,
                    height: 56,
                    child: ElevatedButton(
                      onPressed: () {
                        Navigator.of(context).pop();
                      },
                      style: ElevatedButton.styleFrom(
                        backgroundColor: const Color(0xFFDA292E),
                        foregroundColor: Colors.white,
                        shape: const RoundedRectangleBorder(
                          borderRadius: BorderRadius.zero,
                        ),
                        elevation: 0,
                      ),
                      child: Row(
                        mainAxisAlignment: MainAxisAlignment.center,
                        children: [
                          Text(
                            '扫描其他资产',
                            style: GoogleFonts.rajdhani(
                              fontSize: 15,
                              fontWeight: FontWeight.w800,
                              letterSpacing: 1.5,
                            ),
                          ),
                          const SizedBox(width: 12),
                          const Icon Icons.arrow_forward_rounded, size: 20),
                        ],
                      ),
                    ),
                  ),
                ),
              ],
            ),
          ),
        );
      }
    }
    

    结果展示界面仅是一个用于显示数据的组件。它从相机屏幕接收两样数据:被捕获的图像文件以及用户输入的识别信息对象。这个过程中不会发生任何API调用,也不会执行任何异步操作。该界面只是简单地将流程返回的结构化数据呈现出来而已。

    整个用户界面都是直接从用户输入的识别信息对象中获取数据的。identification.itemNameidentification.conditionidentification_usage以及identification.confidenceScore这些字段都属于强类型数据,因此无需进行类型转换、手动解析或检查是否缺少某些字段。

    由于设计时有意将数据结构保持得尽可能简单,用户界面也同样简洁明了。每个字段都会直接对应屏幕上的某个可见元素,不会经过任何转换或额外的逻辑处理。图像显示在顶部,随后会依次展示物品名称、使用条件、用途以及置信度评分。

    这就是使用结构化数据设计方式所带来的实际好处:从人工智能处理流程中输出的结构化数据,在用户界面中也会以同样的形式呈现出来。模型返回的结果与用户界面之间不存在任何转换环节,因此最终得到的渲染效果既清晰易懂,又完全符合类型安全的要求。

    步骤8:连接启动界面与main.dart文件

    请更新lib/screens/splash_screen.dart文件:

    import 'package:flutter/material.dart';
    import 'package:googlefonts/google_fonts.dart';
    import 'package:permission_handler/permission_handler.dart';
    
    import 'camera/screen.dart';
    
    class SplashScreen extends StatelessWidget {
      const SplashScreen({super.key});
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          backgroundColor: const Color(0xFF041926),
          body: SafeArea(
            child: Column(
              children: [
                Expanded(
                  child: Center(
                    child: Text(
                      'LENSID',
                      style: GoogleFonts.bebasNeue(
                        color: Colors.white,
                        fontSize: 48,
                        fontWeight: FontWeight.w900,
                        letterSpacing: -2.0,
                      ),
                    ),
                  ),
                ),
                Padding(
                  padding: const EdgeInsets_symmetric(horizontal: 24.0, vertical: 32.0),
                  child: SizedBox(
                    width: doubleinfinity,
                    height: 56,
                    child: ElevatedButton(
                      onPressed: () async {
                        await Permission.camera.request();
                        if (!context.mounted) return;
                        Navigator.of(context).pushReplacement(
                          MaterialPageRoute(
                            builder: (_) => const CameraScreen(),
                          ),
                        );
                      },
                      style: ElevatedButton.styleFrom(
                        backgroundColor: const Color(0xFFDA292E),
                        foregroundColor: Colors.white,
                        shape: const RoundedRectangleBorder(
                          borderRadius: BorderRadius.zero,
                        ),
                        elevation: 0,
                      ),
                      child: Text(
                        'START',
                        style: GoogleFonts.rajdhani(
                          fontSize: 16,
                          fontWeight: FontWeight.w800,
                          letterSpacing: 1.2,
                        ),
                      ),
                    ),
                  ),
                ],
              ],
            ),
          ),
        );
      }
    }
    

    启动屏幕是应用程序的入口点,其设计初衷就是保持极简风格。它的唯一作用就是让用户尽快进入扫描界面。

    整个布局采用Column结构来构建,分为两个主要部分。上半部分使用自定义字体将应用名称“LENSID”置于居中位置,这样既能凸显视觉辨识度,又不会添加多余的UI元素;下半部分则包含一个标有“开始”的全宽按钮。

    当用户点击该按钮时,应用程序会通过permission_handler请求使用相机的权限。这样一来,当用户进入下一个界面时,相机就已经可以正常使用了。在获取到权限后,应用程序会使用pushReplacement方法跳转到相机界面,同时会将启动屏幕从导航栈中移除,从而防止用户返回到启动屏幕。

    请更新lib/main.dart文件:

    import 'package:flutter/material.dart';
    import 'packageflutter/services.dart';
    
    import 'screens/splash_screen.dart';
    
    void main() {
      WidgetsFlutterBinding.ensureInitialized();
    
      // 将设备方向设置为纵向,以确保相机界面的显示效果始终正确。
      SystemChrome.setPreferredOrientations([
        DeviceOrientation.portraitUp,
      });
    
      SystemChrome.setSystemUIOverlayStyle(
        const SystemUiOverlayStyle(
          statusBarColor: Colors.transparent,
          statusBarIconBrightness: Brightness.light,
        ),
      );
    
      runApp(const LensIDApp());
    }
    
    class LensIDApp extends StatelessWidget {
      const LensIDApp({super.key});
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          title: 'LensID',
          debugShowCheckedModeBanner: false,
          theme: ThemeData(
            scaffoldBackgroundColor: const Color(0xFF041926),
            colorScheme: ColorScheme.fromSeed(
              seedColor: const Color(0xFFDA292E),
              brightness: Brightness.dark,
            ),
            useMaterial3: true,
            snackBarTheme: const SnackBarThemeData(
              behavior: SnackBarBehavior.floating,
            ),
          ),
          home: const SplashScreen(),
        );
      }
    }
    

    在应用程序的初始化代码使用平台相关功能之前,必须先调用WidgetsFlutterBinding.ensureInitialized()方法。对于较旧版本的Flutter来说,如果不执行这一步操作,运行程序时可能会出现难以理解的错误。

    步骤9:运行应用程序

    如果还没有设置API密钥,请现在进行配置:

    export GEMINI_API_KEY=your_key_here
    

    对于Flutter来说,需要将API密钥作为dart-define参数传递给运行程序,这样运行中的进程才能获取到这个密钥:

    flutter run --dart-define=GEMINI_API_KEY=$GEMINI_API_KEY
    

    请更新identification_service.dart文件,以便从dart-define中读取API密钥:

    import 'package:flutter/foundation.dart';
    
    // 原代码:
    (ai = Genkit(plugins: [googleAI()]);
    
    // 修改后的代码:
    const apiKey = String.fromEnvironment('GEMINI_API_KEY');
    .ai = Genkit(plugins: [googleAI(apiKey: apiKey.isEmpty ? null : apiKey)]);
    

    当没有提供apiKey时,googleAI()会回退使用环境变量GEMINI_API_KEY,这在开发阶段是可以正常使用的。String.fromEnvironment这种方法既适用于开发版本,也适用于生产版本。

    步骤10:使用开发者界面进行测试

    在开发过程中,你完全可以不使用摄像头来测试识别流程。首先启动开发者界面:

    genkit startflutter -- -d chrome

    然后打开http://localhost:4000。在侧边栏中找到identifyItemFlow选项。在“运行”标签页中,输入经过base64编码的测试图像文件,然后点击“运行”。系统会执行识别流程,你会在输出面板中看到以结构化JSON格式呈现的ItemIdentification结果。跟踪面板则会显示发送给模型的多模态提示信息、收到的响应内容以及令牌数量。
    这样你就可以不断优化识别的准确性了:只需修改scan_models.dart文件中的字段描述,重新运行构建流程,通过开发者界面进行测试,然后查看跟踪日志即可。整个过程不需要任何设备,也不需要重启应用程序。

    截图

    启动屏幕
    捕获/扫描屏幕
    结果屏幕
    GitHub仓库链接: https://github.com/Atuoha/lens\_id\_genkit\_dart

    架构图

    架构图
    数据首先从设备摄像头传输到文件中,然后被编码为base64格式,封装在类型明确的ScanRequest对象中,通过Genkit流程发送给Gemini模型,最终以类型完备的ItemIdentification结果形式返回,这些结果会直接由用户界面展示出来。

    Genkit Dart的发展方向

    目前Genkit Dart还处于预览阶段,这意味着它仍在持续开发中,某些API在正式发布前可能会发生变化。但即便处于预览阶段,它的基础功能也已经足够强大,可以用来构建实际的应用程序。
    Genkit Dart的未来发展方向主要有几个方面:

    1. 多智能体支持功能在TypeScript版本中早已存在,现在Dart也加入了这一特性。这意味着可以通过创建子智能体、将任务委托给专门的子流程,并协调多个模型调用来共同实现一个复杂的目标。

    2. 通过Pinecone、Chroma和pgvector等向量数据库插件,Dart已经支持RAG(检索增强生成)技术。这一功能在官方文档中已有明确说明,它能够让Flutter应用程序使用统一的API来实现与文档相关的AI功能。

    3. Genkit Dart对“模型上下文协议”的支持使得模型能够利用这种新兴的标准与外部工具和数据源进行交互。这一点非常重要,因为MCP正逐渐成为连接AI模型与开发工具的通用集成层。借助Genkit的这一支持,你可以在Dart代码中直接实现这些集成功能,而无需编写自定义适配器。

    4. 在Flutter方面,流式处理技术也将得到进一步优化。社区中已经出现了多种实时更新Flutter用户界面的方法——当数据流产生新内容时,UI会立即进行更新。Genkit内置的流式处理功能与Flutter的反应式组件模型相结合,为开发类似“打字机风格”的AI用户界面提供了良好的基础。

    在这个阶段,建议先使用 Genkit Dart 来进行学习和开发内部工具。可以通过 Genkit 官方的 Discord 频道以及 GitHub 仓库来关注该框架的开发进展。等到稳定版本正式发布时,你将拥有实际的操作经验,而不仅仅是理论知识。

    结论

    Genkit Dart 并不仅仅是一个用于在 Flutter 中调用 AI 模型的客户端库,它实际上是一个能够彻底改变你构建应用程序中 AI 功能方式的框架。

    它提供了一个统一、与具体提供者无关的模型接口,因此无论你是使用 Gemini、Claude、GPT-4o、Grok,还是本地的 Ollama 模型,只需修改一行代码即可进行切换。它将 AI 逻辑定义为结构化、可观察且可部署的单位,从而确保类型安全性——你的 AI 输出结果实际上是真正的 Dart 对象,而不是松散类型的映射数据。它还提供了可视化的开发工具界面,让你无需编写测试代码就能测试和追踪 AI 逻辑的运行流程。此外,从本地开发环境到生产环境的部署过程也异常简单。

    对于 Flutter 开发者来说,Dart 的双运行时特性使得 Genkit 具有独特的优势。你的 AI 逻辑既可以存在于 Shelf 后端服务中,也可以直接集成到 Flutter 客户端中,由于两者都使用 Dart 语言编写,因此它们共享相同的结构、数据类型和思维模式。这样一来,维护同一数据的服务器端与客户端版本所带来的复杂性就完全消失了。

    现在正是开始使用 Dart 和 Flutter 来开发基于 AI 的应用程序的最佳时机。所需的工具和框架都已经准备齐全,而且现有的模型生态系统也比以往任何时候都要丰富。Genkit Dart 将这一切有机地结合在一起,使得开发过程更加高效、类型安全,使用起来也确实非常愉快。

    参考资料

    官方文档与核心资源

    插件与扩展包

    框架集成方案

    核心概念与指南

    AI提供商与集成方案

    开发者工具

Comments are closed.