
简介这是一款基于Flutter框架开发的跨平台移动应用专为e621与e926两大图像社区用户设计面向熟悉Dart语言、有移动端开发经验或希望快速上手社区API集成的开发者。应用完整实现浏览/搜索帖子与画廊池、评论互动、图片下载、收藏管理、标签关注与Wiki预览、本地黑名单、DText解析及视频播放等核心功能并支持多主题切换与自动更新检查覆盖iOS与Android双端部署流程。资源包为29.59MB的ZIP压缩包共185个文件其中108个Dart源码文件构成主体逻辑如client.dart网络层、detail.dart详情页、video_frame.dart视频组件辅以32个PNG图标资源、7个JPG示例图、4个XML布局及Gradle构建配置等结构清晰、模块职责分明便于二次开发与功能定制。已有3163人学习下载可直接运行调试、参考完整Flutter工程组织方式、学习社区类App的API封装与状态管理实践。1. 这不是另一个图床Appe1547 是专为 e621/e926 社区设计的 Flutter 移动客户端解决的是「原生体验断层」问题你有没有试过在手机上刷 e621 或 e926网页版缩放卡顿、标签筛选反人类、收藏夹同步靠手动、长按保存图总被拦截——这不是你网络差是这两个站点压根没官方移动 App。e1547 就是冲着这个空白来的它用 Dart Flutter 实现全平台Android/iOS原生渲染把 e621/e926 的 API 能力封装成可离线缓存的本地化交互流。重点不在“能看图”而在“能像用 Instagram 那样滑、筛、存、设提醒”。它不改后端、不绕规则、不模拟登录所有请求走官方公开 API数据完全透明。适合三类人常通勤刷图的社区老用户、想基于 e621 数据做轻量分析的 Dart 初学者、以及正在找 Flutter 真实项目练手的移动端开发者。它不是玩具 Demo而是已跑通完整内容生命周期的工程级客户端——从首页瀑布流加载、到带布尔逻辑的 tag 搜索、再到收藏状态双向同步每一步都踩在社区真实使用路径上。2. 为什么选 Flutter 而非 React Native 或原生Dart 生态与 e621 API 特性的硬匹配2.1 e621/e926 API 的三个硬约束决定了技术栈不能“套模板”e621 和 e926 共享同一套后端架构其 API 设计有鲜明特征分页强依赖page参数而非游标每次请求必须传整数页码且无next_cursor字段导致前端必须维护连续页序跳页/刷新需重置状态Tag 查询支持嵌套布尔表达式如rating:safe AND (cat OR dog) NOT -nsfw后端解析为 AST客户端需预校验语法并结构化提交图片元数据粒度极细但分散分辨率、文件大小、帧率GIF/WebP、AI 生成标识、多个 artist 字段等全部在单次/posts.json响应中返回字段数超 40 个JSON 解析性能敏感。这些不是“加个 loading 就行”的小问题。React Native 的 JS Bridge 在高频滚动大量 JSON 解析时易掉帧纯 Kotlin/Swift 虽快但双端要写两套 tag 解析器、两套缓存策略、两套离线队列——开发成本翻倍。而 Dart 的 JIT/AOT 双模编译、内置json_serializable代码生成、以及freezed对复杂嵌套对象的不可变建模能力刚好卡在痛点上一次写 model两端零差异一次写 tag parser直接复用到搜索页和高级筛选弹窗。2.2 e1547 的核心模块拆解Flutter 里怎么把“API 调用”变成“状态流”整个 App 的数据流严格遵循BLoC Repository 分层但关键在于 Repository 层对 e621 API 的适配逻辑// lib/data/repositories/post_repository.dart class PostRepository { final Dio _dio; PostRepository(this._dio); // 关键page 参数不拼在 URL 里而是作为独立参数传入 FuturePagedResultPost fetchPosts({ required int page, String? tags, int limit 20, }) async { final response await _dio.get(/posts.json, queryParameters: { page: page, // 强制整数避免字符串1导致后端解析失败 limit: limit, tags: tags ?? , // 空字符串允许获取全部 json: 1, // 显式声明规避某些 CDN 缓存歧义 }); // 后端返回的 pagination 字段是 { count: 12345, per_page: 20, current_page: 1 } final pagination response.data[pagination] as MapString, dynamic; return PagedResult( items: (response.data[posts] as List) .map((p) Post.fromJson(p)) .toList(), currentPage: pagination[current_page], totalPages: (pagination[count] / pagination[per_page]).ceil(), totalCount: pagination[count], ); } }提示page必须传int类型e621 后端对page1和page1处理不一致——后者可能返回空数组。这是文档没写的隐性契约e1547 在 BLoC 中做了类型强制转换避免上游误传。2.3 Dart 的freezed如何应对 e621 的“元数据爆炸”e621 的Post对象含 40 字段其中tags是 map 结构{ general: [...], species: [...], character: [...] }file是嵌套对象含width,height,size,ext,url等。若用json_serializable手动写fromJson极易漏字段或类型错配。e1547 采用freezedjson_serializable组合// lib/data/models/post.dart freezed class Post with _$Post { const factory Post({ required int id, required String created_at, required String updated_at, required int score, required int fav_count, required bool has_notes, required bool has_comments, required bool is_pending, required bool is_flagged, required bool is_deleted, required bool is_banned, required bool has_children, Default([]) ListString sources, required Tags tags, required File file, required ListString artists, required ListString pools, required ListString relationships, }) _Post; factory Post.fromJson(MapString, dynamic json) _$PostFromJson(json); } freezed class Tags with _$Tags { const factory Tags({ Default([]) ListString general, Default([]) ListString species, Default([]) ListString character, Default([]) ListString copyright, Default([]) ListString artist, Default([]) ListString invalid, }) _Tags; }生成的fromJson会自动处理嵌套、默认值、类型断言。当 e621 新增ai_generated: true字段时只需在Postclass 中加一行required bool ai_generated运行build_runner build即可生成新解析器——不用改任何业务逻辑。3. 本地缓存策略SQLite Hive 双轨制解决“离线刷图”和“快速启动”矛盾3.1 为什么不用单一缓存方案e621 场景下的读写权重撕裂e1547 的缓存不是“为了缓存而缓存”而是被两个强需求撕扯出来的离线可用性地铁进隧道前点开“favorite”页出来后仍能滑动浏览已加载的图且收藏状态同步冷启动速度App 杀后台后重新打开首页瀑布流必须在 300ms 内渲染出首屏 6 张缩略图不能等网络请求。单一 SQLite 存全部字段查询慢40 字段 JOIN、写入阻塞 UIGIF 元数据插入耗时高只用 Hive 存简单对象无法做 tag 模糊搜索、无法关联收藏状态。e1547 的解法是Hive 存热数据缩略图 URL、ID、score、tags 字符串、SQLite 存全量结构化数据含 file 对象、artists 数组、relationships两者通过post_id关联。3.2 Hive 缓存用BoxPostPreview实现毫秒级首屏PostPreview是精简模型仅保留首屏渲染必需字段// lib/data/cache/hive_cache.dart HiveType(typeId: 0) class PostPreview extends HiveObject { HiveField(0) final int id; HiveField(1) final String previewUrl; // 150x150 缩略图 HiveField(2) final int score; HiveField(3) final String tagsString; // cat dog rating:safe HiveField(4) final bool isFavorited; // 本地收藏状态与服务器最终一致 PostPreview({ required this.id, required this.previewUrl, required this.score, required this.tagsString, required this.isFavorited, }); }初始化时创建 Box// 初始化 Hive await Hive.initFlutter(); final box await Hive.openBoxPostPreview(post_previews); // 首屏加载直接查 box.values.take(6) final previews box.values.take(6).toList();注意tagsString不是原始Tags对象而是tags.general.join( ) tags.species.join( )的扁平化字符串用于后续box.where((p) p.tagsString.contains(cat))快速过滤。这是空间换时间的典型取舍——多存 2KB 字符串换来 10ms 内完成 1000 条记录的 tag 筛选。3.3 SQLite 全量存储用moor自动生成类型安全 DAOe1547 选用moor现为drift而非sqflite原生写法核心原因是e621 的字段变更频繁。例如 2023 年新增upset_score字段2024 年file.ext改为file.extname。moor的.drift文件定义表结构编译时自动生成 DAO-- lib/data/database/posts.drift /* 注意此 SQL 严格对应 e621 API 返回的字段名 如 file.width → file_width避免驼峰转下划线歧义 */ CREATE TABLE posts ( id INT PRIMARY KEY, created_at TEXT NOT NULL, score INT NOT NULL, fav_count INT NOT NULL, file_width INT, file_height INT, file_size INT, file_ext TEXT, file_url TEXT, tags_general TEXT, -- 存 JSON 字符串[cat,dog] tags_species TEXT, artists TEXT, -- [artist1,artist2] is_favorited BOOLEAN DEFAULT false );生成的PostsCompanion自动处理ListString到 JSON 字符串的序列化// 插入时自动序列化 await into(posts).insert(postsCompanion( id: post.id, file_width: post.file.width, file_height: post.file.height, file_ext: post.file.ext, tags_general: jsonEncode(post.tags.general), // 自动 artists: jsonEncode(post.artists), // 自动 ));提示jsonEncode是必须的因为 SQLite 无原生 array 类型。e1547 在读取时用jsonDecode还原但加了 try-catch 防止后端字段异常导致整条记录解析失败——这是线上环境血泪经验某次 e621 接口临时返回tags_general: null未加防护的 App 直接 crash。4. 避坑e621 API 调用中 4 个真实翻车现场与修复方案4.1 现象搜索rating:safe返回空结果但网页端正常原因e621 的tags参数对空格敏感rating:safe和rating: safe带空格被后端视为不同 tag而 Flutter 的Uri.encodeComponent()会将空格编码为%20但 e621 实际期望的是或原样空格。更隐蔽的是某些 CDN 会把%20重写为导致测试环境正常、生产环境失效。解决禁用Uri.encodeComponent()改用白名单替换String safeTagEncode(String tag) { return tag .replaceAll( , ) // 空格→ .replaceAll((, %28) .replaceAll(), %29) .replaceAll(:, %3A); // 仅编码冒号其他保留 } // 使用queryParameters[tags] safeTagEncode(rating:safe AND cat);4.2 现象收藏按钮点击后 UI 状态立即变更但 2 秒后又回退原因e621 的收藏 APIPOST /posts/{id}/favorites是异步队列返回200 OK仅代表入队成功实际收藏状态需轮询/posts/{id}.json获取最新fav_count和is_favorited。e1547 初始版本只改本地状态未做服务端状态确认。解决引入“乐观更新 最终一致性”模式Futurevoid toggleFavorite(int postId) async { // 1. 乐观更新本地状态 _updateLocalFavoriteStatus(postId, !currentState); // 2. 发起收藏请求 await _apiClient.toggleFavorite(postId); // 3. 1.5 秒后拉取最新状态e621 通常 1s 内完成 await Future.delayed(const Duration(milliseconds: 1500)); final freshPost await _postRepository.fetchPostById(postId); _updateLocalFavoriteStatus(postId, freshPost.isFavorited); }4.3 现象Android 12 上长按图片保存失败报SecurityException原因Android 12 强制要求MediaStore写入必须用MediaStore.createWriteRequest()而 e1547 初始用File.writeAsBytes()直接写入getExternalStorageDirectory()触发权限拒绝。解决切换至media_kit插件 MediaStoreAPI// Android 专用保存逻辑 if (Platform.isAndroid _androidSdkVersion 30) { final resolver context.contentResolver; final values ContentValues(); values.put(MediaStore.MediaColumns.DISPLAY_NAME, e1547_${post.id}.jpg); values.put(MediaStore.MediaColumns.MIME_TYPE, image/jpeg); final uri resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values); if (uri ! null) { await resolver.openOutputStream(uri)?.writeAllBytes(imageBytes); } } else { // 旧版逻辑 final file File(${dir.path}/${post.id}.jpg); await file.writeAsBytes(imageBytes); }4.4 现象iOS 上搜索含中文 tag如猫返回 0 结果原因e621 的 tag 系统对 Unicode 处理不一致——猫在数据库中存为cat的别名但 API 搜索时需传cat传猫会被忽略。而网页端前端做了自动映射App 未复现该逻辑。解决内置中文 tag 映射表轻量级仅覆盖高频 200 个const MapString, String chineseToEnglishTag { 猫: cat, 狗: dog, 兔子: rabbit, 兽耳: anthro_ears, 尾巴: tail, }; // 搜索前预处理 String normalizeTags(String rawTags) { for (final entry in chineseToEnglishTag.entries) { rawTags rawTags.replaceAll(entry.key, entry.value); } return rawTags; }5. 高级技巧用flutter_gen自动生成资源引用彻底消灭硬编码字符串5.1 为什么资源路径硬编码是 Flutter 项目的慢性毒药e1547 的图标、占位图、错误图分散在assets/icons/、assets/images/placeholder.png、assets/images/error.png等目录。初期用Image.asset(assets/icons/home.png)看似无害但一旦图标重命名home.png→home_active.png所有引用处需手动改新增深色模式图标assets/icons/home_dark.png需在 10 个 Widget 中加Theme.of(context).brightness Brightness.dark判断团队协作时设计师扔来新图标却忘了通知路径变更CI 构建直接失败。e1547 用flutter_gen彻底终结这个问题。配置pubspec.yamlflutter_gen: output: lib/gen/ integrations: flutter_svg: true assets: - assets/icons/ - assets/images/运行dart run build_runner build后自动生成lib/gen/assets.gen.dart// lib/gen/assets.gen.dart class AssetsGen { static const _icons _IconsGen(); static const _images _ImagesGen(); static const icons _icons; static const images _images; } class _IconsGen { const _IconsGen(); String get home assets/icons/home.png; String get search assets/icons/search.png; String get favorite assets/icons/favorite.png; String get favorite_filled assets/icons/favorite_filled.png; } class _ImagesGen { const _ImagesGen(); String get placeholder assets/images/placeholder.png; String get error assets/images/error.png; }5.2 深色模式自动适配用flutter_genThemeMode实现零配置切换flutter_gen支持theme_mode智能识别。在pubspec.yaml中声明flutter_gen: # ... 其他配置 assets: - assets/icons/light/ - assets/icons/dark/约定命名规则home.pnglight和home_dark.pngdark则生成的代码自动包含class _IconsGen { const _IconsGen(); String get home Theme.of(context).brightness Brightness.light ? assets/icons/light/home.png : assets/icons/dark/home.png; // 其他图标同理 }但 e1547 更进一步用InheritedWidget提前注入主题上下文避免在每个 Widget 中调用Theme.of(context)// lib/widgets/theme_aware_image.dart class ThemeAwareImage extends StatelessWidget { final String assetName; // 如 icons/home const ThemeAwareImage({super.key, required this.assetName}); override Widget build(BuildContext context) { final theme Theme.of(context); final path theme.brightness Brightness.light ? assets/$assetName.png : assets/$assetName${theme.brightness Brightness.dark ? _dark : }.png; return Image.asset(path); } }然后全局搜索替换Image.asset(AssetsGen.icons.home)→ThemeAwareImage assetNameicons/home /。从此设计师改一个文件名全项目图标自动更新加一套深色图标无需改一行业务代码。5.3 动态语言包用flutter_gen生成l10n类让多语言真正可维护e1547 支持英语、日语、简体中文。传统做法是建app_en.arb、app_ja.arb、app_zh.arb再用flutter pub run pigeon生成S.of(context).homeTitle。但问题在于新增一个字符串需在 3 个 ARB 文件中手动补全漏一个就 crash翻译人员不懂 Dart直接改 ARB 文件易格式错误逗号、引号无法在 IDE 中跳转到字符串定义处。e1547 用flutter_gen的l10n集成flutter_gen: # ... 其他配置 l10n: enabled: true output: lib/gen/l10n/ input: lib/l10n/lib/l10n/app_en.arb{ homeTitle: Home, searchHint: Search posts..., noResults: No posts found }lib/l10n/app_zh.arb{ homeTitle: 首页, searchHint: 搜索图片..., noResults: 没有找到图片 }生成lib/gen/l10n/app_localizations.dart提供类型安全访问Text(S.of(context).homeTitle), // 自动根据 Localizations.locale 选择语言 // IDE 支持 CtrlClick 跳转到 ARB 文件定义血泪经验某次上线前翻译同学在app_zh.arb中误删了searchHint的逗号导致 JSON 解析失败App 启动白屏。e1547 后来加了 CI 检查dart run intl_utils:generate_from_arb --output-dirlib/gen/l10n/ lib/l10n/app_en.arb失败则阻断构建。现在语言包不再是发布雷区而是可自动化验证的工程资产。从那以后我每次新增资源、改图标、加语言都强制走一遍flutter_gen生成流程再git diff确认生成文件是否符合预期——这步 10 秒的操作省去了后期 3 小时的排查。希望帮到你。本文还有配套的精品资源点击获取