一、需求背景

在金融领域,用户经常需要从图表、报表、新闻截图等图片中获取信息。原系统仅支持文字输入,用户需要先将图片中的内容手动打字输入,这种方式不仅效率低下,还容易出现信息遗漏。因此,图片输入功能成为提升用户体验的关键需求。

功能目标

  • 支持从相册选择图片或直接拍照上传
  • 自动解析图片内容,结合金融知识库进行问答
  • 跨平台支持:Android、iOS、Web

二、功能设计

2.1 使用场景分析

场景 示例 用户需求
图表解析 用户上传股票K线图 解析图表数据,解答相关问题
报表识别 上传财务报表截图 提取关键财务指标
新闻配图 金融新闻中的截图 根据图片内容回答相关问题
数据对比 多张图片对比分析 综合分析多图信息

2.2 技术选型

图片选择库image_picker
选择理由

  • 官方维护,稳定性好
  • 支持 Android、iOS、Web 全平台
  • API 简洁,易于集成
  • 支持相册选择和相机拍照两种方式

三、开发实现

3.1 前端开发

3.1.1 依赖配置
# pubspec.yaml
dependencies:
  image_picker: ^1.0.4
  permission_handler: ^11.3.1
3.1.2 核心代码实现

状态定义

final ImagePicker _imagePicker = ImagePicker();
String? _selectedImagePath;
Uint8List? _selectedImageBytes; // Web平台使用

图片选择方法

Future<void> _pickImage(ImageSource source) async {
    // 1. 调用系统图片选择器
    final XFile? image = await _imagePicker.pickImage(source: source);
    
    if (image != null) {
        // 2. 根据平台处理图片数据
        if (kIsWeb) {
            // Web平台:读取图片字节数据
            final bytes = await image.readAsBytes();
            setState(() {
                _selectedImagePath = image.name; // 保存文件名用于显示
                _selectedImageBytes = bytes;
            });
        } else {
            // 移动端/桌面端:保存文件路径
            setState(() {
                _selectedImagePath = image.path;
                _selectedImageBytes = null;
            });
        }
    }
}

UI 交互实现

// 图片选择按钮
PopupMenuButton<ImageSource>(
    icon: const Icon(Icons.add_photo_alternate),
    onSelected: _pickImage,
    itemBuilder: (context) => [
        PopupMenuItem(
            value: ImageSource.gallery,
            child: Row(
                children: [
                    Icon(Icons.image, size: 18),
                    SizedBox(width: 8),
                    Text('从相册选择'),
                ],
            ),
        ),
        PopupMenuItem(
            value: ImageSource.camera,
            child: Row(
                children: [
                    Icon(Icons.camera_alt, size: 18),
                    SizedBox(width: 8),
                    Text('拍照'),
                ],
            ),
        ),
    ],
)

发送问答

Future<void> _ask() async {
    if (_questionCtrl.text.isEmpty && _selectedImagePath == null) {
        return;
    }
    
    // 调用 Provider 发送问答请求
    await context.read<KnowledgeQaProvider>().askQuestion(
        _questionCtrl.text.isEmpty ? '[图片]' : _questionCtrl.text,
        userLevel: _userLevel,
        imagePath: kIsWeb ? null : _selectedImagePath,
        imageBytes: kIsWeb ? _selectedImageBytes : null,
        imageFileName: _selectedImagePath,
    );
}

3.2 后端开发

3.2.1 API 接口设计

采用 FastAPI 的文件上传机制,支持 multipart/form-data 格式:

@router.post("/ask/multimodal", summary="多模态金融知识问答")
async def ask_question_multimodal(
    question: str = Form(...),
    user_level: str = Form('入门'),
    image: UploadFile = File(None),
    audio: UploadFile = File(None),
    user_id: str = Depends(get_current_user_id),
):
    image_path = None
    
    try:
        # ============ 图片处理 ============
        if image:
            # 创建上传目录
            upload_root = Path(settings.UPLOAD_DIR)
            upload_root.mkdir(parents=True, exist_ok=True)
            
            # 生成唯一文件名,避免冲突
            image_path = upload_root / f"mm_{uuid.uuid4().hex}_{image.filename}"
            
            # 保存文件
            with image_path.open("wb") as out:
                shutil.copyfileobj(image.file, out)
            
            logger.info(f"[知识问答] 上传图片: {image.filename} -> {image_path}")
        
        # ============ 调用问答服务 ============
        result = await rag_service.answer_question_with_multimodal(
            question=question,
            user_level=user_level,
            image_path=str(image_path) if image_path else None,
        )
        
        return ResponseBase(data=result)
    
    finally:
        # ============ 清理临时文件 ============
        if image_path and image_path.exists():
            image_path.unlink(missing_ok=True)
3.2.2 多模态问答服务
class RAGService:
    async def answer_question_with_multimodal(
        self,
        question: str,
        user_level: str = "入门",
        image_path: Optional[str] = None,
        audio_path: Optional[str] = None,
    ) -> Dict[str, Any]:
        """
        多模态问答:支持图片和音频输入的问答。
        
        Args:
            question: 用户问题
            user_level: 用户水平
            image_path: 图片路径
            audio_path: 音频路径
        """
        normalized_level = self._normalize_user_level(user_level)
        
        media_info = []
        if image_path:
            media_info.append(f"图片路径: {image_path}")
        if audio_path:
            media_info.append(f"音频路径: {audio_path}")
        
        logger.info(
            f"[RAGService] 多模态问答: '{question[:50]}' "
            f"level={normalized_level} media={len(media_info)}"
        )
        
        # 调用 LLM 进行问答(图片会在内部进行 OCR + 图像理解)
        # ...

3.3 流式输出支持

为了提升用户体验,支持流式输出,让用户能边生成边看到内容:

@router.post("/ask/multimodal/stream", summary="多模态金融知识问答(流式输出)")
async def ask_question_multimodal_stream(
    question: str = Form(...),
    user_level: str = Form('入门'),
    image: UploadFile = File(None),
    audio: UploadFile = File(None),
    user_id: str = Depends(get_current_user_id),
):
    # ... 图片处理逻辑同上 ...
    
    async def event_generator():
        async for event in rag_service.stream_answer_question_with_multimodal(
            question=question,
            user_level=user_level,
            image_path=str(image_path) if image_path else None,
            audio_path=str(audio_path) if audio_path else None,
        ):
            yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
    
    return StreamingResponse(
        event_generator(),
        media_type="text/event-stream"
    )

四、平台兼容性处理

4.1 Web 平台特殊处理

Web 平台的文件处理与移动端不同,需要特别注意:

特性 移动端 Web 平台
文件引用 文件路径 Uint8List 字节数据
文件名 从路径提取 直接获取
上传方式 multipart/form-data Base64 编码

Web 端上传服务

Future<Map<String, dynamic>> _uploadImage({
    required String question,
    required String userLevel,
    required Uint8List imageBytes,
    required String fileName,
}) async {
    final uri = Uri.parse('$baseUrl/ask/multimodal');
    final request = http.MultipartRequest('POST', uri);
    
    // 添加表单字段
    request.fields['question'] = question;
    request.fields['user_level'] = userLevel;
    
    // 添加图片文件(Web平台使用bytes)
    request.files.add(http.MultipartFile.fromBytes(
        'image',
        imageBytes,
        filename: fileName,
    ));
    
    final streamedResponse = await request.send();
    final response = await http.Response.fromStream(streamedResponse);
    
    return jsonDecode(response.body);
}

4.2 权限处理

不同平台需要不同的权限声明:

Android (AndroidManifest.xml)

<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-feature android:name="android.hardware.camera" android:required="false" />

iOS (Info.plist)

<key>NSCameraUsageDescription</key>
<string>需要相机权限来拍照上传</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>需要相册权限来选择图片</string>

Web:无需配置权限,由浏览器自动处理。

五、开发问题与解决方案

5.1 图片文件过大的问题

问题:用户上传大图片导致上传慢、响应时间长。

解决方案

// 在选择图片时限制大小
final XFile? image = await _imagePicker.pickImage(
    source: source,
    maxWidth: 1920,  // 最大宽度
    maxHeight: 1080, // 最大高度
    imageQuality: 85, // 图片质量 0-100
);

5.2 图片格式兼容性问题

问题:某些特殊格式(如 HEIC)在不同平台显示不一致。

解决方案

  • 后端统一转换为标准格式(如 JPEG)处理
  • 前端限制可选格式:image_picker 默认支持 JPEG、PNG 等常见格式

5.3 临时文件清理

问题:上传的图片文件如果不清理会占用服务器存储空间。

解决方案:采用 try-finally 模式确保临时文件被清理:

try:
    if image:
        # 保存文件
        with image_path.open("wb") as out:
            shutil.copyfileobj(image.file, out)
    
    # 处理业务逻辑
    result = await rag_service.answer_question_with_multimodal(...)
    return ResponseBase(data=result)

finally:
    # 无论成功失败都清理临时文件
    if image_path and image_path.exists():
        image_path.unlink(missing_ok=True)

六、软件工程实践

6.1 DRY 原则(Don’t Repeat Yourself)

在图片处理逻辑中,我们复用了多模态问答的服务层,语音和图片共用一套处理流程:

6.2 关注点分离

层级 职责
UI 层 用户交互、图片选择、状态展示
Provider 层 业务逻辑编排、状态管理
Service 层 API 调用、数据转换
后端 API 请求处理、文件管理
后端 Service 业务逻辑、LLM 调用

6.3 用户体验优化

  1. 即时反馈:选择图片后立即显示预览
  2. 流式输出:边生成边展示,减少等待焦虑
  3. 错误处理:上传失败时显示友好提示
  4. 进度提示:大文件上传时显示加载动画

七、总结

本次图片输入功能的开发,让我对跨平台移动开发有了更深入的理解:

  1. 平台差异需要针对性处理:Web 和移动端的文件处理方式完全不同,需要在架构层面就考虑兼容性问题
  2. 用户体验是核心竞争力:好的 UX 设计(如流式输出、即时预览)能显著提升产品竞争力
  3. 安全性不容忽视:文件上传功能需要做好文件类型验证、大小限制等安全措施
  4. 资源清理很重要:临时文件必须及时清理,避免占用服务器存储

图片输入功能的上线,使得用户可以更便捷地获取金融知识,无论是分析股票图表还是理解财务报告,都能得到智能化的问答支持。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐