☰
e1547:基于Flutter的e621/e926原生移动客户端实践
2026/10/15 15:42:12 网站建设 项目流程

简介:这是一款基于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. 这不是另一个图床App:e1547 是专为 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 里,而是作为独立参数传入 Future<PagedResult<Post>> 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 Map<String, 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 后端对page=1和page="1"处理不一致——后者可能返回空数组。这是文档没写的隐性契约,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 采用freezed+json_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([]) List<String> sources, required Tags tags, required File file, required List<String> artists, required List<String> pools, required List<String> relationships, }) = _Post; factory Post.fromJson(Map<String, dynamic> json) => _$PostFromJson(json); } @freezed class Tags with _$Tags { const factory Tags({ @Default([]) List<String> general, @Default([]) List<String> species, @Default([]) List<String> character, @Default([]) List<String> copyright, @Default([]) List<String> artist, @Default([]) List<String> 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)、写入阻塞 UI(GIF 元数据插入耗时高);只用 Hive 存简单对象?无法做 tag 模糊搜索、无法关联收藏状态。e1547 的解法是Hive 存热数据(缩略图 URL、ID、score、tags 字符串)、SQLite 存全量结构化数据(含 file 对象、artists 数组、relationships),两者通过post_id关联。

3.2 Hive 缓存:用Box<PostPreview>实现毫秒级首屏

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.openBox<PostPreview>('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自动生成类型安全 DAO

e1547 选用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自动处理List<String>到 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 的收藏 API(POST /posts/{id}/favorites)是异步队列,返回200 OK仅代表入队成功,实际收藏状态需轮询/posts/{id}.json获取最新fav_count和is_favorited。e1547 初始版本只改本地状态,未做服务端状态确认。
解决:引入“乐观更新 + 最终一致性”模式:

Future<void> 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 Map<String, 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.yaml:

flutter_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_gen+ThemeMode实现零配置切换

flutter_gen支持theme_mode智能识别。在pubspec.yaml中声明:

flutter_gen: # ... 其他配置 assets: - assets/icons/light/ - assets/icons/dark/

约定命名规则:home.png(light)和home_dark.png(dark),则生成的代码自动包含:

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 assetName="icons/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 支持 Ctrl+Click 跳转到 ARB 文件定义

血泪经验:某次上线前,翻译同学在app_zh.arb中误删了searchHint的逗号,导致 JSON 解析失败,App 启动白屏。e1547 后来加了 CI 检查:dart run intl_utils:generate_from_arb --output-dir=lib/gen/l10n/ lib/l10n/app_en.arb,失败则阻断构建。现在,语言包不再是发布雷区,而是可自动化验证的工程资产。

从那以后我每次新增资源、改图标、加语言,都强制走一遍flutter_gen生成流程,再git diff确认生成文件是否符合预期——这步 10 秒的操作,省去了后期 3 小时的排查。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询