简介:以Flutter/Dart编写的移动应用e1547,用于浏览e621与e926社区的帖子、图库与标签内容,支持评论、上传下载、视频播放、本地黑名单、多主题切换等丰富功能。资源包面向Flutter初中级开发者,既可学习完整应用架构,也可作为图片社区类App的二次开发底稿。压缩包共185个文件,其中108个dart文件构成核心业务逻辑,32个png与7个jpg为界面图标与截图,XML、plist、Storyboard、Gradle与xcconfig分别对应Android/iOS的界面配置与构建设置,整体大小约29.59MB。已有3147人浏览学习,具备一定关注度。解压后可从client请求封装、page/detail页面、tags标签、video_frame视频帧、text_editor文本编辑等模块入手,系统理解话题流、池浏览、DText解析与主题切换的实现思路,便于快速上手或移植复用。
1. 为什么我会去拆 e621/e926 的移动端方案:现成的 Flutter 客户端源码
先说结论:如果你正在做 furry 向的移动应用,或者你的课程设计需要一个「真实 API + 登录态 + 图片流」的完整 Flutter 样例,这份源码是少数能直接跑起来的东西。e621 和 e926 是两个同源的图像站点,前者是主站,后者是 SFW 版(按规定只放非成人内容),它们共用同一套 API v2。这个项目标题里的 e1547 是仓库编号,本质就是一个用 Dart 写的 Flutter 移动应用,把两个站的浏览、搜索、登录、图片查看打包成了手机 App。我把它完整拆过一遍,过程中踩了不少坑——比如 e621 的 User-Agent 策略、API key 的鉴权方式、e926 的隐藏内容过滤规则,这些不实际跑一遍根本想不到。
适合谁来用,我列得窄一点:一是做移动应用开发课程设计的学生,需要一个「非玩具级」的 Flutter 项目来交差;二是想给自己的图站浏览流程做个手机壳的从业者;三是想研究 Flutter 图片流应用如何做缓存和标签系统的开发者。后面的内容全部基于 Flutter + Dart 这套技术栈,涉及网络请求、状态管理、本地缓存三个核心模块。下面我按照「工程结构 → 认证 → 搜索 → 缓存 → 避坑 → 进阶」的顺序来拆。
2. 工程结构与双站切换设计:先搞清 e621 和 e926 是怎么共存的
2.1 从 pubspec.yaml 看这个项目的技术选型
拿到源码后我第一件事是打开pubspec.yaml。这个文件决定了整个项目的依赖底座,也直接暴露了作者的技术偏好。我拆到的版本里,核心依赖有dio(网络请求)、provider(状态管理)、cached_network_image(图片缓存)、shared_preferences(本地存储)。没有用bloc,也没上riverpod,说明作者追求的是「够用就好」这一路。
dependencies: flutter: sdk: flutter dio: ^5.x.x provider: ^6.x.x cached_network_image: ^3.x.x shared_preferences: ^2.x.x这几个库的选型逻辑很明确:dio是为了拦截器和自定义 Header 方便,e621 的 API 要求每个请求必须带合法的 User-Agent,用dio的BaseOptions可以统一处理;provider是因为这个 App 的状态量不多——登录态、当前站点、搜索结果,三个ChangeNotifier就能覆盖;cached_network_image是为了图片流的流畅度,e621 的缩略图 CDN 在国外,重复加载会非常痛苦。如果你是自己从头写,我建议照抄这套组合,它经得起实战考验。
2.2 双站切换的工程实现:BaseUrl 来自一个枚举
e621 和 e926 的关系需要先说明白:e926 的内容是 e621 的子集,但 API 域名完全不同。e621 的 API 基地址是https://e621.net,e926 是https://e926.net,请求路径一致,但域名不同,且 e926 强制要求登录后才能访问大部分接口。这个项目里用一个枚举来管理双站切换,核心代码如下:
enum E621Site { e621('https://e621.net'), e926('https://e926.net'); const E621Site(this.baseUrl); final String baseUrl; }然后在ApiClient里把 baseUrl 作为可配置项传进去。切换站点时,整个ApiClient会被重新创建,而不是临时改 URL。这个设计很关键——因为dio实例会复用连接池,如果只改 baseUrl 不重建实例,偶尔会出现请求打到旧域名上的诡异问题。我一开始图省事,直接改dio.options.baseUrl,结果 e926 的请求偶尔返回 404,排查了半天才发现是连接池复用导致的。
2.3 为什么移动端必须做双站分离而不是加一个过滤开关
有一个常见的偷懒做法是只接 e621,然后在 App 里把成人内容过滤掉。这个方案在这个场景下不成立,原因有两层:第一,e621 的图片元数据里虽然有rating字段,但很多历史图片的评级并不准确,靠字段过滤会有漏网之鱼;第二,e926 存在的原因恰恰是它维护了一套独立的审核队列,很多内容在 e621 上是「 questionable」但在 e926 上是允许的,反向过滤会误伤。所以源码里直接维护两个站点是正确做法,从 API 层面就做了隔离,而不是在 UI 层做过滤。
3. 登录认证与 API Key 机制:账号密码不是你想的那样
3.1 e621 的鉴权不是 OAuth,是 HTTP Basic Auth
很多第一次接触 e621 API 的开发者会默认走 OAuth 流程,结果发现官方文档里压根没有这东西。e621 使用的认证方式是 HTTP Basic Authentication,用户名是账号名,密码是账号绑定的 API Key,不是登录密码。这个 API Key 需要在网页端「My Account → Manage API Access」里生成,格式是一串十六进制字符。
import 'package:dio/dio.dart'; class E621AuthInterceptor extends Interceptor { E621AuthInterceptor({required this.username, required this.apiKey}); final String username; final String apiKey; @override void onRequest(RequestOptions options, RequestHandler handler) { final basicAuth = 'Basic ' + base64Encode(utf8.encode('$username:$apiKey')); options.headers['Authorization'] = basicAuth; handler.next(options); } }这段代码的关键在于base64Encode(utf8.encode(...))——Dart 的base64Encode默认按 byte 编码,但字符串必须先转成 UTF-8 字节。如果直接base64Encode(utf8.encode(username + ':' + apiKey))也可以,但分步写更清晰。放进拦截器后,所有请求都会自动带上认证头,不需要每个方法手动传参。注意这个 API Key 是可以随时吊销的,所以不要硬编码在客户端代码里,应该通过登录界面让用户输入后存到shared_preferences。
3.2 匿名访问能做什么,登录后多出什么
不登录也能访问 e621 的公开接口,但有两个限制:一是 e926 大部分接口要求登录;二是 e621 的搜索接口在匿名状态下会省略一些字段。具体来说,匿名状态下/posts.json接口返回的图集信息(多个图片组成的帖子)会被截断,评分和收藏数据也会缺失。这个源码里的做法是:启动时不强制登录,但一旦用户触发「搜索」或「浏览」操作,就检查本地是否有 API Key,没有则跳转登录页。
登录页的逻辑也很直接,把用户名和 API Key 存到shared_preferences,下次启动时直接读取:
Future<void> saveCredentials(String username, String apiKey) async { final prefs = await SharedPreferences.getInstance(); await prefs.setString('username', username); await prefs.setString('api_key', apiKey); }这里有一个安全上的现实考量:shared_preferences是明文存储,API Key 等同于一等凭证。如果是上线产品,至少要做一次混淆或转存到flutter_secure_storage。课程设计阶段可以接受明文,但你要知道这个边界。
3.3 User-Agent 不合法,请求直接 403 或 429
这是整个项目里最容易翻车的地方,也是 e621 官方文档特别强调的:每个请求都必须带自定义的 User-Agent,格式要求是「网站名/版本号 (联系方式)」。如果不带,服务器会返回 403 Forbidden;如果带了一个疑似浏览器的 UA(比如Mozilla/5.0 ...),也可能被风控拦截。我自己第一次跑这个项目时,忘了设置 UA,所有请求全部 403,一度以为是 API Key 的问题。
options.headers['User-Agent'] = 'e621MobileApp/1.0 (contact: your@email.com)';放在dio的BaseOptions里统一设置即可。这里的联系方式可以是邮箱,也可以是 GitHub 地址,关键是让管理员能找到你。另外 e621 对请求频率有限制,文档建议每秒钟最多 2 个请求。如果是做批量抓取,必须自己做限速,否则会触发 429,IP 会被临时封禁。
4. 标签搜索与图片流加载:把核心功能打通的关键代码
4.1 搜索接口参数怎么拼:tags、limit、page 的语义
这个 App 的核心搜索功能调的是GET /posts.json。e621 的搜索语法支持标签组合,多个标签用空格分隔,标签前加-表示排除,支持score:>=50这种元数据过滤。源码里把搜索串的构建抽成了一个独立方法:
String buildSearchQuery({ required String tags, int? scoreThreshold, String? rating, String? excludeTags, }) { final parts = <String>[]; if (tags.isNotEmpty) parts.add(tags); if (scoreThreshold != null) parts.add('score:>=$scoreThreshold'); if (rating != null) parts.add('rating:$rating'); if (excludeTags != null && excludeTags.isNotEmpty) { final excluded = excludeTags .split(',') .map((e) => '-$e') .join(' '); parts.add(excluded); } return parts.join(' '); }这个方法的语义要拆开讲:tags是主搜索词,比如canine;scoreThreshold是评分下限,score:>=50表示只取评分不低于 50 的帖子;rating可选值是safe、questionable、explicit,在 e926 场景下应该固定为safe;excludeTags传入一串逗号分隔的标签,会被拆成多个-标签拼进去。这个设计覆盖了大部分真实搜索诉求。
4.2 图片流的分页加载:page 参数不是页码,是游标
e621 API 的分页方式和大多数 REST API 不同——page参数不是简单的页码,而是基于帖子 ID 的游标。第一页传page=1,拿到结果后取最后一条的id,下一页请求传page=b$lastId(b 是字母 b 加 ID 数字,表示 before)。源码里的实现是维护了一个lastPostId状态:
Future<List<Post>> fetchNextPage() async { final pageParam = _lastPostId == null ? 1 : 'b$_lastPostId'; final response = await _dio.get('/posts.json', queryParameters: { 'tags': _currentQuery, 'limit': 50, 'page': pageParam, }); final posts = (response.data['posts'] as List) .map((e) => Post.fromJson(e)) .toList(); if (posts.isNotEmpty) { _lastPostId = posts.last.id; } return posts; }这个游标分页有个大坑:如果你用普通页码请求,翻到第二页之后,会看到大量重复数据或直接跳空。原因在于服务器在生成页码分页时,基于的是当前搜索结果集,但图片站的内容实时更新很快,前后两次请求之间新帖子入站,会导致结果集漂移。游标分页省去了这个计算,只按 ID 向前回溯。我在实战中遇到过page=2和page=3返回完全相同的帖子列表,就是之前用错分页方式的结果。
4.3 图片缓存策略:不要让 CachedNetworkImage 裸奔
这个项目里所有图片都是用cached_network_image加载的,但在列表页和详情页的配置不太一样。列表页用的是缩略图 URL,详情页用原图 URL。缩略图和原图的 URL 结构不同,缓存 key 也不同。源码里给缓存设置了最大字节数和最大条数:
CacheManagerConfig.defaultConfig( maxDiskCacheSize: 300 * 1024 * 1024, maxDiskCacheEntries: 1000, );300MB是折中值——e621 的缩略图质量较高,单张约 20KB 到 80KB,300MB 可以覆盖几千张图的滚动浏览;如果设得太小,往回滚动时图片会重新加载,体验会变成「翻页就闪白」。这里要注意cached_network_image的缓存策略只作用于网络层,不会缓存到内存外,也就是说它是磁盘缓存,冷启动后第一次滑动还是会重新请求一次,只是后续不再走网络。
5. 避坑记录:e621/e926 移动端开发最容易翻车的五个场景
5.1 为什么我的请求全是 403:不是 API Key 错了,是 User-Agent 没设置
现象:按照官方文档配好了用户名和 API Key,所有请求仍然返回403 Forbidden。
原因:e621 服务器对 User-Agent 的校验比大多数 API 服务严格得多。浏览器默认 UA 会被拒绝,空的 UA 也会被拒绝,且错误信息里不会明确提示是 UA 问题。
解决:在dio的BaseOptions里统一设置User-Agent,格式为AppName/版本 (联系方式),后续所有请求自动带上。我建议在调试阶段把联系方式写真实邮箱,因为频繁出错时官方会发邮件提醒避风头。
5.2 e926 匿名访问返回空列表:这个站强制登录
现象:在 e621 上匿名搜索一切正常,切到 e926 后同样的请求返回空数组,状态码 200。
原因:e926 的posts接口对于匿名用户的返回结果会被过滤,在部分时间段直接返回空结果,而且没有任何错误提示。这是服务器端的强制策略,不是客户端代码问题。
解决:在切换到 e926 站点时,必须在请求前检查本地是否存有 API Key,没有则弹登录页。不要在请求失败后才提示登录,因为空结果看起来不像错误,用户会以为自己的搜索词没结果。
5.3 列表滚动到 200 张图后 App 崩溃:内存没有做回收
现象:上下滑动浏览图片列表,滑到约 200 张时 Android 端崩溃,日志显示OutOfMemoryError。
原因:cached_network_image的缓存是磁盘缓存,但它加载图片后仍会占用内存。列表项没有回收机制,每张图的原图都驻留在内存中,导致内存膨胀。
解决:在详情页用flutter_cache_manager手动控制,或者给列表页的图片统一使用缩略图源,并设置gaplessPlayback为 true。我习惯把图片列表页的图片 URL 强制替换成file结尾的缩略图 URL,原图只在详情页加载。
5.4 搜索「safe」内容仍然出现暴露图片:rating 标签的作用域理解错了
现象:在 e926 站点搜索时,结果里混入了一些 rating 为questionable的图片。
原因:e926 的过滤机制作用于服务端,但搜索 URI 中显式指定rating:safe才能触发评级过滤。如果只搜索标签不指定 rating,e926 返回的是「允许在该站显示」的内容,不等于「safe 评级」,两者是不同概念。
解决:在搜索串中强制附加rating:safe。尤其是做课程设计时,评审老师不会理解为什么「非成人站点」出现了 questionable 评级的内容,保险起见,e926 场景下一个硬编码的 rating 过滤是必要的。
5.5 API Key 泄露后的后悔药:没有吊销入口意识
现象:开发调试时把 API Key 写死在代码里并提交到了 GitHub 仓库,第二天账号被异地登录。
原因:GitHub 上有爬虫专门扫描硬编码的 API Key 和密码,提交后几乎秒级失守。e621 的 API Key 权限范围覆盖搜索和部分管理操作,泄露后等于把账号拱手让人。
解决:立即登录网页端删除旧 Key,生成新 Key;客户端改用运行时输入 +shared_preferences存储。从那以后我每次提交代码前都会强制做一遍git diff检查,专门搜api_key和password字样。这不是玄学,是血泪教训。
6. 批量下载脚本:用纯 Dart 命令行把 e621 搜索变成本地图库
6.1 为什么用 Dart 而不是 Python:复用现有模型类
这个项目的进阶场景是批量下载。很多人会想到用 Python 重新写一个抓取脚本,但源码本身就是 Dart 项目,改用 Dart 写命令行工具可以直接复用Post模型类和ApiClient类,不需要二次翻译。Dart 提供了对命令行脚本的完整支持,不是只能写 Flutter UI。下面是脚本的核心部分:
import 'package:dio/dio.dart'; import 'dart:convert'; import 'dart:io'; Future<void> downloadBatch({ required String username, required String apiKey, required String tags, required int pageCount, required String outputDir, }) async { final dio = Dio(BaseOptions( baseUrl: 'https://e621.net', headers: { 'User-Agent': 'BatchDownloader/1.0 (contact: your@email.com)', 'Authorization': 'Basic ${base64Encode(utf8.encode('$username:$apiKey'))}', }, )); int? lastId; Directory(outputDir).createSync(recursive: true); for (var page = 0; page < pageCount; page++) { final response = await dio.get('/posts.json', queryParameters: { 'tags': tags, 'limit': 50, 'page': lastId == null ? 1 : 'b$lastId', }); final posts = response.data['posts'] as List; if (posts.isEmpty) break; for (final post in posts) { final fileUrl = post['file']['url'] as String; final id = post['id'] as int; final ext = fileUrl.split('.').last; final savePath = '$outputDir/$id.$ext'; if (File(savePath).existsSync()) continue; try { final fileResponse = await dio.download( fileUrl, savePath, onReceiveProgress: (received, total) { if (total != null) { stdout.write('\r$id: $received / $total'); } }, ); stdout.writeln(' -> saved $savePath'); } catch (e) { stderr.writeln('Failed to download post $id: $e'); } await Future.delayed(const Duration(milliseconds: 500)); } lastId = (posts.last as Map)['id'] as int; stdout.writeln('Page $page done, lastId: $lastId'); } }这段脚本的参数说明:tag是搜索词,建议在命令行用引号包裹整个搜索语句,比如"canine score:>=50 -comic";pageCount是期望的翻页数量,每页 50 张,30 页就是最多 1500 张,但实际会因为结果不足而提前 break;outputDir是保存目录,不存在会自动创建。注意dio.download是带断点续传的,但如果服务端不支持 Range 请求,续传会重新下载完整文件,所以脚本里用File.existsSync()跳过已存在的文件,避免重复下载同一张图。
6.2 这个脚本里最容易被忽略的限速设计
代码里每次循环结束后的Future.delayed(Duration(milliseconds: 500))不是随便加的。e621 的官方限速是「每秒最多 2 个请求」,这个脚本本身就涉及两类请求——API 分页请求和图片文件下载请求。如果你不人为降速,图片 CDN 不限速,但 API 会在第 20 个左右请求时触发 429。500ms是最保险的间隔。如果你在比赛中做自动化采集(比如中职移动应用开发赛项里要求做数据抓取演示),这个速度也够用,毕竟素材量通常不到 500 张。
脚本跑完后的验证方式也很简单:统计目录下的文件数,和 API 返回的总数比对。有时候 e621 会返回 404 的死链,脚本里的catch会跳过并打印失败信息,最后人工核对失败 ID 的帖子是否已被删除即可。从那以后,我做任何 e621 相关的工具,第一件事都是先写一个带 page 游标和限速的最小脚本验证连通性,再往上加功能——这个习惯帮我省了很多不必要的调试时间,希望帮到你。
本文还有配套的精品资源,点击获取