Flutter头像上传:使用Riverpod实现选择上传实时更新完整解决方案

📸 使用 Riverpod 实现头像立即上传

  1. 完整的实现方案

项目结构

复制代码
lib/
├── main.dart
├── providers/
│   ├── avatar_provider.dart
│   └── providers.dart
├── services/
│   └── upload_service.dart
├── widgets/
│   └── avatar_upload_widget.dart
└── pages/
    └── profile_page.dart
  1. Provider 定义

lib/providers/avatar_provider.dart

dart 复制代码
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import '../services/upload_service.dart';

// 上传状态
enum AvatarUploadStatus {
  idle,       // 空闲
  selecting,  // 选择中
  uploading,  // 上传中
  success,    // 成功
  error,      // 失败
}

// 状态模型
class AvatarState {
  final AvatarUploadStatus status;
  final File? selectedImage;
  final String? imageUrl;
  final double progress;
  final String? errorMessage;

  AvatarState({
    this.status = AvatarUploadStatus.idle,
    this.selectedImage,
    this.imageUrl,
    this.progress = 0,
    this.errorMessage,
  });

  AvatarState copyWith({
    AvatarUploadStatus? status,
    File? selectedImage,
    String? imageUrl,
    double? progress,
    String? errorMessage,
  }) {
    return AvatarState(
      status: status ?? this.status,
      selectedImage: selectedImage ?? this.selectedImage,
      imageUrl: imageUrl ?? this.imageUrl,
      progress: progress ?? this.progress,
      errorMessage: errorMessage ?? this.errorMessage,
    );
  }

  bool get isIdle => status == AvatarUploadStatus.idle;
  bool get isSelecting => status == AvatarUploadStatus.selecting;
  bool get isUploading => status == AvatarUploadStatus.uploading;
  bool get isSuccess => status == AvatarUploadStatus.success;
  bool get hasError => status == AvatarUploadStatus.error;
}

// Notifier 类
class AvatarNotifier extends StateNotifier<AvatarState> {
  final UploadService uploadService;
  final ImagePicker imagePicker;

  AvatarNotifier({
    required this.uploadService,
    required this.imagePicker,
  }) : super(AvatarState());

  // 选择图片并立即上传
  Future<void> pickAndUploadImage({
    required ImageSource source,
    required String userId,
  }) async {
    try {
      // 1. 选择图片
      state = state.copyWith(
        status: AvatarUploadStatus.selecting,
        errorMessage: null,
      );

      final XFile? pickedFile = await imagePicker.pickImage(
        source: source,
        maxWidth: 800,
        maxHeight: 800,
        imageQuality: 85,
      );

      if (pickedFile == null) {
        state = state.copyWith(status: AvatarUploadStatus.idle);
        return;
      }

      final File imageFile = File(pickedFile.path);

      // 2. 更新预览状态
      state = state.copyWith(
        status: AvatarUploadStatus.uploading,
        selectedImage: imageFile,
        progress: 0,
      );

      // 3. 立即上传
      final String imageUrl = await uploadService.uploadAvatar(
        imageFile: imageFile,
        userId: userId,
        onProgress: (double progress) {
          // 更新进度
          state = state.copyWith(progress: progress);
        },
      );

      // 4. 上传成功
      state = state.copyWith(
        status: AvatarUploadStatus.success,
        imageUrl: imageUrl,
        progress: 1.0,
        selectedImage: null, // 清除临时文件引用
      );

    } catch (error) {
      // 5. 错误处理
      state = state.copyWith(
        status: AvatarUploadStatus.error,
        errorMessage: error.toString(),
        progress: 0,
      );
      
      // 可选:3秒后重置错误状态
      Future.delayed(const Duration(seconds: 3), () {
        if (state.hasError) {
          state = state.copyWith(status: AvatarUploadStatus.idle);
        }
      });
    }
  }

  // 清除错误
  void clearError() {
    if (state.hasError) {
      state = state.copyWith(
        status: AvatarUploadStatus.idle,
        errorMessage: null,
      );
    }
  }

  // 手动设置头像URL(用于初始加载)
  void setInitialAvatar(String? imageUrl) {
    state = state.copyWith(imageUrl: imageUrl);
  }

  // 重置状态
  void reset() {
    state = AvatarState();
  }
}

// Provider 定义
final avatarProvider = StateNotifierProvider<AvatarNotifier, AvatarState>(
  (ref) {
    return AvatarNotifier(
      uploadService: ref.watch(uploadServiceProvider),
      imagePicker: ImagePicker(),
    );
  },
);

lib/providers/providers.dart

dart 复制代码
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'avatar_provider.dart';

// 导出所有 provider
export 'avatar_provider.dart';

// 其他依赖的 provider
final uploadServiceProvider = Provider<UploadService>((ref) {
  return UploadService();
});
  1. 上传服务

lib/services/upload_service.dart

dart 复制代码
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;

class UploadService {
  // 模拟上传,实际项目中替换为真实API
  Future<String> uploadAvatar({
    required File imageFile,
    required String userId,
    required void Function(double progress) onProgress,
  }) async {
    try {
      // 模拟上传延迟
      await Future.delayed(const Duration(milliseconds: 100));
      
      // 模拟进度更新
      for (int i = 0; i <= 10; i++) {
        await Future.delayed(const Duration(milliseconds: 100));
        onProgress(i / 10);
      }

      // 实际项目中,这里应该是真实的API调用
      /*
      final uri = Uri.parse('https://your-api.com/upload/avatar');
      final request = http.MultipartRequest('POST', uri)
        ..files.add(await http.MultipartFile.fromPath(
          'avatar',
          imageFile.path,
        ))
        ..fields['userId'] = userId;

      final streamedResponse = await request.send();
      
      // 监听进度
      streamedResponse.stream.listen(
        (List<int> chunk) {
          final total = streamedResponse.contentLength ?? 0;
          final uploaded = chunk.length;
          onProgress(uploaded / total);
        },
      );

      final response = await http.Response.fromStream(streamedResponse);
      
      if (response.statusCode == 200) {
        final jsonResponse = json.decode(response.body);
        return jsonResponse['data']['url'];
      } else {
        throw Exception('上传失败: ${response.statusCode}');
      }
      */

      // 模拟返回的图片URL
      await Future.delayed(const Duration(milliseconds: 500));
      return 'https://picsum.photos/400/400?random=${DateTime.now().millisecondsSinceEpoch}';

    } catch (e) {
      throw Exception('上传失败: $e');
    }
  }
}
  1. 上传组件

lib/widgets/avatar_upload_widget.dart

dart 复制代码
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../providers/avatar_provider.dart';

class AvatarUploadWidget extends ConsumerWidget {
  final String userId;
  final double size;
  final bool showEditButton;
  final VoidCallback? onUploadSuccess;

  const AvatarUploadWidget({
    Key? key,
    required this.userId,
    this.size = 100,
    this.showEditButton = true,
    this.onUploadSuccess,
  }) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(avatarProvider);
    
    return Center(
      child: Stack(
        alignment: Alignment.bottomRight,
        children: [
          // 头像显示
          _buildAvatar(state, context),
          
          // 编辑按钮
          if (showEditButton && !state.isUploading)
            _buildEditButton(context, ref),
          
          // 上传进度指示器
          if (state.isUploading) _buildProgressIndicator(state),
          
          // 错误提示
          if (state.hasError) _buildErrorOverlay(state, ref),
        ],
      ),
    );
  }

  Widget _buildAvatar(AvatarState state, BuildContext context) {
    // 如果有选中的图片(正在上传中),显示预览
    if (state.selectedImage != null && state.isUploading) {
      return _buildImagePreview(state.selectedImage!);
    }
    
    // 如果有上传成功的图片URL,显示网络图片
    if (state.imageUrl != null && state.imageUrl!.isNotEmpty) {
      return _buildNetworkImage(state.imageUrl!);
    }
    
    // 默认头像
    return _buildDefaultAvatar();
  }

  Widget _buildImagePreview(File imageFile) {
    return Container(
      width: size,
      height: size,
      decoration: BoxDecoration(
        shape: BoxShape.circle,
        border: Border.all(
          color: Colors.blue.withOpacity(0.3),
          width: 2,
        ),
      ),
      child: ClipOval(
        child: Image.file(
          imageFile,
          fit: BoxFit.cover,
          width: size,
          height: size,
        ),
      ),
    );
  }

  Widget _buildNetworkImage(String imageUrl) {
    return Container(
      width: size,
      height: size,
      decoration: BoxDecoration(
        shape: BoxShape.circle,
        border: Border.all(
          color: Colors.grey.shade300,
          width: 2,
        ),
      ),
      child: ClipOval(
        child: CachedNetworkImage(
          imageUrl: imageUrl,
          width: size,
          height: size,
          fit: BoxFit.cover,
          placeholder: (context, url) => Center(
            child: CircularProgressIndicator(
              strokeWidth: 2,
              valueColor: AlwaysStoppedAnimation<Color>(Colors.blue),
            ),
          ),
          errorWidget: (context, url, error) => _buildDefaultAvatar(),
        ),
      ),
    );
  }

  Widget _buildDefaultAvatar() {
    return Container(
      width: size,
      height: size,
      decoration: BoxDecoration(
        shape: BoxShape.circle,
        color: Colors.grey.shade200,
        border: Border.all(
          color: Colors.grey.shade300,
          width: 2,
        ),
      ),
      child: Icon(
        Icons.person,
        size: size * 0.5,
        color: Colors.grey.shade400,
      ),
    );
  }

  Widget _buildEditButton(BuildContext context, WidgetRef ref) {
    return GestureDetector(
      onTap: () => _showImageSourceDialog(context, ref),
      child: Container(
        width: size * 0.3,
        height: size * 0.3,
        decoration: BoxDecoration(
          shape: BoxShape.circle,
          color: Colors.blue,
          border: Border.all(
            color: Colors.white,
            width: 2,
          ),
          boxShadow: [
            BoxShadow(
              color: Colors.black.withOpacity(0.1),
              blurRadius: 4,
              offset: Offset(0, 2),
            ),
          ],
        ),
        child: const Icon(
          Icons.camera_alt,
          size: 18,
          color: Colors.white,
        ),
      ),
    );
  }

  Widget _buildProgressIndicator(AvatarState state) {
    return Positioned.fill(
      child: Container(
        decoration: BoxDecoration(
          shape: BoxShape.circle,
          color: Colors.black.withOpacity(0.5),
        ),
        child: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              SizedBox(
                width: size * 0.4,
                height: size * 0.4,
                child: CircularProgressIndicator(
                  value: state.progress,
                  strokeWidth: 3,
                  backgroundColor: Colors.white30,
                  valueColor: AlwaysStoppedAnimation<Color>(Colors.blue),
                ),
              ),
              SizedBox(height: 8),
              Text(
                '${(state.progress * 100).toInt()}%',
                style: TextStyle(
                  color: Colors.white,
                  fontSize: 12,
                  fontWeight: FontWeight.bold,
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

  Widget _buildErrorOverlay(AvatarState state, WidgetRef ref) {
    return Positioned.fill(
      child: GestureDetector(
        onTap: () => ref.read(avatarProvider.notifier).clearError(),
        child: Container(
          decoration: BoxDecoration(
            shape: BoxShape.circle,
            color: Colors.red.withOpacity(0.8),
          ),
          child: Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                const Icon(
                  Icons.error_outline,
                  color: Colors.white,
                  size: 30,
                ),
                SizedBox(height: 4),
                Padding(
                  padding: const EdgeInsets.symmetric(horizontal: 8),
                  child: Text(
                    '上传失败',
                    style: const TextStyle(
                      color: Colors.white,
                      fontSize: 12,
                      fontWeight: FontWeight.bold,
                    ),
                    textAlign: TextAlign.center,
                    maxLines: 2,
                  ),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }

  void _showImageSourceDialog(BuildContext context, WidgetRef ref) {
    showModalBottomSheet(
      context: context,
      shape: const RoundedRectangleBorder(
        borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
      ),
      builder: (context) => SafeArea(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            const SizedBox(height: 16),
            Text(
              '更换头像',
              style: TextStyle(
                fontSize: 18,
                fontWeight: FontWeight.bold,
                color: Colors.grey.shade800,
              ),
            ),
            const SizedBox(height: 16),
            _buildOptionButton(
              context: context,
              icon: Icons.camera_alt,
              text: '拍照',
              onTap: () {
                Navigator.pop(context);
                _uploadFromSource(ImageSource.camera, ref);
              },
            ),
            _buildOptionButton(
              context: context,
              icon: Icons.photo_library,
              text: '从相册选择',
              onTap: () {
                Navigator.pop(context);
                _uploadFromSource(ImageSource.gallery, ref);
              },
            ),
            const SizedBox(height: 16),
            TextButton(
              onPressed: () => Navigator.pop(context),
              child: const Text('取消'),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildOptionButton({
    required BuildContext context,
    required IconData icon,
    required String text,
    required VoidCallback onTap,
  }) {
    return ListTile(
      leading: Icon(icon, color: Colors.blue),
      title: Text(text),
      onTap: onTap,
    );
  }

  void _uploadFromSource(ImageSource source, WidgetRef ref) {
    ref.read(avatarProvider.notifier).pickAndUploadImage(
      source: source,
      userId: userId,
    );
    
    // 监听上传成功
    ref.listen<AvatarState>(avatarProvider, (previous, next) {
      if (next.isSuccess && onUploadSuccess != null) {
        onUploadSuccess!();
      }
    });
  }
}
  1. 个人资料页面

lib/pages/profile_page.dart

dart 复制代码
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../widgets/avatar_upload_widget.dart';
import '../providers/avatar_provider.dart';

class ProfilePage extends ConsumerStatefulWidget {
  const ProfilePage({Key? key}) : super(key: key);

  @override
  _ProfilePageState createState() => _ProfilePageState();
}

class _ProfilePageState extends ConsumerState<ProfilePage> {
  final String userId = 'user_123'; // 实际项目中从登录状态获取

  @override
  void initState() {
    super.initState();
    // 页面初始化时加载现有头像
    _loadInitialAvatar();
  }

  Future<void> _loadInitialAvatar() async {
    // 模拟从服务器获取现有头像URL
    await Future.delayed(const Duration(milliseconds: 500));
    
    // 这里应该调用API获取用户头像URL
    // final response = await api.getUserAvatar(userId);
    // ref.read(avatarProvider.notifier).setInitialAvatar(response.avatarUrl);
    
    // 模拟数据
    ref.read(avatarProvider.notifier).setInitialAvatar(
      'https://picsum.photos/400/400?random=123',
    );
  }

  @override
  Widget build(BuildContext context) {
    final state = ref.watch(avatarProvider);

    return Scaffold(
      appBar: AppBar(
        title: const Text('个人资料'),
        centerTitle: true,
        elevation: 0,
      ),
      body: SingleChildScrollView(
        child: Padding(
          padding: const EdgeInsets.all(20),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              // 头像上传区域
              _buildAvatarSection(state),
              
              const SizedBox(height: 30),
              
              // 个人信息
              _buildInfoSection(),
              
              const SizedBox(height: 30),
              
              // 上传状态显示
              if (state.isUploading) _buildUploadStatus(state),
              if (state.hasError) _buildErrorStatus(state, ref),
              if (state.isSuccess) _buildSuccessStatus(),
            ],
          ),
        ),
      ),
    );
  }

  Widget _buildAvatarSection(AvatarState state) {
    return Card(
      elevation: 4,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(16),
      ),
      child: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          children: [
            Text(
              '个人头像',
              style: TextStyle(
                fontSize: 18,
                fontWeight: FontWeight.bold,
                color: Colors.grey.shade800,
              ),
            ),
            const SizedBox(height: 20),
            AvatarUploadWidget(
              userId: userId,
              size: 120,
              onUploadSuccess: () {
                // 上传成功后的回调
                ScaffoldMessenger.of(context).showSnackBar(
                  SnackBar(
                    content: Text('头像更新成功!'),
                    backgroundColor: Colors.green,
                  ),
                );
              },
            ),
            const SizedBox(height: 16),
            Text(
              state.isUploading ? '正在上传...' : '点击右下角相机更换头像',
              style: TextStyle(
                fontSize: 14,
                color: Colors.grey.shade600,
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildInfoSection() {
    return Card(
      elevation: 4,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(16),
      ),
      child: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              '个人信息',
              style: TextStyle(
                fontSize: 18,
                fontWeight: FontWeight.bold,
                color: Colors.grey.shade800,
              ),
            ),
            const SizedBox(height: 20),
            _buildInfoItem('用户名', 'Flutter用户'),
            _buildInfoItem('邮箱', 'user@example.com'),
            _buildInfoItem('注册时间', '2024-01-01'),
          ],
        ),
      ),
    );
  }

  Widget _buildInfoItem(String label, String value) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 8),
      child: Row(
        children: [
          Expanded(
            flex: 2,
            child: Text(
              label,
              style: TextStyle(
                fontSize: 16,
                color: Colors.grey.shade600,
              ),
            ),
          ),
          Expanded(
            flex: 3,
            child: Text(
              value,
              style: TextStyle(
                fontSize: 16,
                fontWeight: FontWeight.w500,
              ),
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildUploadStatus(AvatarState state) {
    return Card(
      color: Colors.blue.shade50,
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Row(
          children: [
            CircularProgressIndicator(
              value: state.progress,
              strokeWidth: 3,
            ),
            const SizedBox(width: 16),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    '正在上传头像...',
                    style: TextStyle(
                      fontWeight: FontWeight.bold,
                      color: Colors.blue.shade800,
                    ),
                  ),
                  const SizedBox(height: 4),
                  LinearProgressIndicator(
                    value: state.progress,
                    backgroundColor: Colors.blue.shade200,
                    valueColor: AlwaysStoppedAnimation<Color>(Colors.blue),
                  ),
                  const SizedBox(height: 4),
                  Text(
                    '${(state.progress * 100).toInt()}%',
                    style: TextStyle(
                      fontSize: 12,
                      color: Colors.blue.shade600,
                    ),
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildErrorStatus(AvatarState state, WidgetRef ref) {
    return Card(
      color: Colors.red.shade50,
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Row(
          children: [
            Icon(Icons.error_outline, color: Colors.red),
            const SizedBox(width: 16),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    '上传失败',
                    style: TextStyle(
                      fontWeight: FontWeight.bold,
                      color: Colors.red.shade800,
                    ),
                  ),
                  Text(
                    state.errorMessage ?? '未知错误',
                    style: TextStyle(
                      fontSize: 12,
                      color: Colors.red.shade600,
                    ),
                    maxLines: 2,
                    overflow: TextOverflow.ellipsis,
                  ),
                ],
              ),
            ),
            TextButton(
              onPressed: () => ref.read(avatarProvider.notifier).clearError(),
              child: Text('关闭'),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildSuccessStatus() {
    return Card(
      color: Colors.green.shade50,
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Row(
          children: [
            Icon(Icons.check_circle, color: Colors.green),
            const SizedBox(width: 16),
            Expanded(
              child: Text(
                '头像更新成功!',
                style: TextStyle(
                  fontWeight: FontWeight.bold,
                  color: Colors.green.shade800,
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}
  1. 主程序入口

lib/main.dart

dart 复制代码
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'pages/profile_page.dart';
import 'providers/providers.dart';

void main() {
  runApp(
    const ProviderScope(
      child: MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '头像上传示例',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
        appBarTheme: const AppBarTheme(
          centerTitle: true,
          elevation: 0,
        ),
      ),
      home: const ProfilePage(),
      debugShowCheckedModeBanner: false,
    );
  }
}
  1. Pubspec.yaml 配置
yaml 复制代码
name: avatar_upload_example
description: A Flutter avatar upload example with Riverpod
publish_to: 'none'
version: 1.0.0+1

environment:
  sdk: ">=2.19.0 <3.0.0"

dependencies:
  flutter:
    sdk: flutter
  
  # 状态管理
  flutter_riverpod: ^2.3.6
  
  # 图片选择
  image_picker: ^1.0.4
  
  # 网络请求
  http: ^0.13.5
  
  # 网络图片缓存
  cached_network_image: ^3.2.3
  
  # 图片裁剪(可选)
  image_cropper: ^5.0.0

flutter:
  uses-material-design: true
  
  # 配置 assets
  assets:
    - assets/images/
  
  # iOS 配置
  # 在 ios/Runner/Info.plist 中添加:
  # <key>NSCameraUsageDescription</key>
  # <string>需要相机权限来拍摄头像照片</string>
  # <key>NSPhotoLibraryUsageDescription</key>
  # <string>需要相册权限来选择头像照片</string>
  
  # Android 配置
  # 在 android/app/src/main/AndroidManifest.xml 中添加:
  # <uses-permission android:name="android.permission.CAMERA"/>
  # <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
  # <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
  1. 关键特性总结

✅ 立即上传流程

  1. 选择图片 → 自动进入选择状态
  2. 显示预览 → 立即显示选中图片
  3. 开始上传 → 自动开始上传并显示进度
  4. 上传成功 → 更新头像并显示成功状态
  5. 错误处理 → 显示错误信息并提供重试

🔄 状态管理

· Riverpod 管理整个上传状态

· 实时更新 上传进度

· 自动重置 错误状态

🎯 用户体验

· 流畅的上传进度显示

· 清晰的错误提示

· 成功状态反馈

· 优雅的加载和过渡效果

🛠️ 扩展建议

  1. 添加图片裁剪功能
  2. 实现断点续传
  3. 支持多图片上传
  4. 添加图片压缩功能
  5. 集成云存储(如 Firebase Storage、阿里云 OSS)

这个实现方案:选择相册或拍照 → 立即上传 → 上传成功更新页面。所有的状态都是实时更新的,用户可以看到完整的上传过程。

相关推荐
程序员Ctrl喵1 天前
异步编程:Event Loop 与 Isolate 的深层博弈
开发语言·flutter
前端不太难1 天前
Flutter 如何设计可长期维护的模块边界?
flutter
小蜜蜂嗡嗡1 天前
flutter列表中实现置顶动画
flutter
始持1 天前
第十二讲 风格与主题统一
前端·flutter
始持1 天前
第十一讲 界面导航与路由管理
flutter·vibecoding
始持1 天前
第十三讲 异步操作与异步构建
前端·flutter
新镜1 天前
【Flutter】 视频视频源横向、竖向问题
flutter
黄林晴1 天前
Compose Multiplatform 1.10 发布:统一 Preview、Navigation 3、Hot Reload 三箭齐发
android·flutter
Swift社区1 天前
Flutter 应该按功能拆,还是按技术层拆?
flutter
肠胃炎1 天前
树形选择器组件封装
前端·flutter