简介:一款基于谷歌Flutter框架、使用Dart语言编写的移动客户端工程源码,用于浏览和交互e621与e926两个在线图站,适合移动端开发者、Flutter进阶学员以及希望自建图站客户端的爱好者。项目功能覆盖较全,包括帖子和图池的浏览搜索、帖子修改与评论、图片上传下载、收藏夹与热门内容访问、标签Wiki查询、本地黑名单、DText富文本解析、视频播放、自动更新检查以及多主题切换,基本覆盖此类工具型App的主要模块。压缩包共185个文件,大小约29.59MB,主体为108个Dart源码文件,并配有PNG/JPG图片资源、Gradle及XML构建配置、iOS工程所需的Plist和Storyboard文件,以及若干GIF、Swift、Kotlin、Shell等辅助脚本,Android与iOS双端工程结构一目了然。目前已有3124人学习下载。通过研读该项目,可以熟悉真实App中的页面路由、网络层封装、状态管理、富文本解析、主题定制与跨端打包等工程实现,也可直接修改复用为个人定制客户端,或作为Flutter综合实战项目用于技术分享与求职展示。
1. e1547 是什么:把 e621 与 e926 装进一个不扎手的手机壳
e1547 是我对一个移动应用的项目代号,它要干的事一句话就能说清:让用户在手机上有一个专门为 e621 和 e926 两个站点服务的客户端,而不是被迫在浏览器里放大缩小、反复回退和忍受无限的重新加载。这类图片站的网页端本身就是为桌面设计的,手机上打开之后标签栏挤成一团、点开大图还要再等一次全屏渲染。移动应用把帖子流、标签搜索、收藏、评分过滤这些高频动作变成原生控件,同时用服务器开放的 JSON 接口做数据源。适合谁?适合那些经常按标签多条件组合搜图、需要把喜欢的帖子存到本地、或者单纯想用一个干净界面替代网页的人。
2. 先定传输层:e621 与 e926 的 API 调用和域名切换怎么做
2.1 e621 和 e926 的关系:不是两个系统,是同一套数据的两种过滤视图
开始写代码之前,必须先把两个站点的关系理清。我一般会把它们理解成同一套 API 协议下的两个视图,而不是两个完全独立的网站。e621 是完整内容的入口,e926 则在域名层面直接过滤掉高评级内容,只保留安全内容。对移动应用来说,这意味着你在做域名切换时,不能只是把 baseUrl 从https://e621.net换成https://e926.net,还要考虑搜索词、缓存、评分过滤逻辑跟着一起切换。
如果两个站点共用登录凭证,那么收藏和投票这类账号操作也必须在同一套认证体系下工作。常见做法是保存同一份用户名与 API 密钥,在请求头里用 Basic Auth 做身份声明。只读浏览不需要登录,但一旦用户要收藏帖子、投票或者发表评论,就必须带着认证信息去请求。开发阶段最省事的做法是先做一个站点开关,把当前域名、当前检索词、当前评分下限都放到同一个状态里管理。
我的路由设计是这样:应用启动时默认走 e621 域名;用户切换安全模式时,域名换成 e926;两个域名共用一个请求封装函数,只是传入的 baseUrl 不同。这样后续所有请求都走同一个入口,出问题时只需要看一层日志。
2.2 最小可用的网络层:User-Agent、超时和分页,一个都不能少
e621 这类站点对请求头非常敏感,尤其是 User-Agent。空 UA 的请求会在入口层被直接拒绝,根本到不了业务逻辑。我习惯为移动端请求封装一个统一的apiGet方法,把 UA、超时和域名切换都收敛在一个文件里,后续调试也只需要改这一个位置。
// 移动端 API 请求核心:站点开关 + 强制 User-Agent const String _e621Base = 'https://e621.net'; const String _e926Base = 'https://e926.net'; String currentBase = _e621Base; Future<http.Response> apiGet(String path, Map<String, String> params) async { final uri = Uri.parse(currentBase + path).replace(queryParameters: params); return http .get(uri, headers: { 'User-Agent': 'e1547/0.1.0 (Android; contact: dev@example.com)', 'Accept': 'application/json', }) .timeout(const Duration(seconds: 12)); }上面这段逻辑并不复杂,但值得说的有三个地方。第一是 UA 串,必须是“应用名/版本号 + 平台 + 联系方式”的组合,联系方式可以放邮箱,官方索引页面看到陌生 UA 时会先读这段信息。第二是超时设成了 12 秒,移动网络下网络抖动很常见,但如果超过 12 秒还没拿到响应,继续等下去只会拖垮用户体验,不如直接抛错让上层走重试或提示。第三是currentBase用全局状态保存,站点切换时只需要重新赋值,不需要改调用方。
这个封装的缺点也很明显:没有自动重试,也没有把鉴权信息统一注入。如果后面要加登录态,需要在这里继续扩展,比如在 headers 里追加 Basic Auth。不过作为最小可用网络层,它已经足够支撑第一版开发。
2.3 每分钟 50 次的配额:为什么必须在客户端做令牌桶
e621 的 API 对请求频率限制得很死。我个人的经验是每 3 秒一个请求比较稳妥,短时间突发的并发请求很容易触发 501 或者 429。移动端用户不会像爬虫一样疯狂请求,但应用内部可能因为图片预加载、自动补全、分页预取同时发出多个请求,这就会把自己挤到限流线上去。
解决思路是在客户端做令牌桶,意思是不管业务层想发多少请求,网络层最多按固定速率放行。每次请求前先消费一个令牌,令牌不足就直接等待。
// 令牌桶限流:让客户端保持慢速,而不是依赖服务器最后兜底 class BooruLimiter { final int maxTokens; final Duration refillInterval; int _tokens; DateTime _lastRefill; BooruLimiter({this.maxTokens = 10, this.refillInterval = const Duration(seconds: 3)}) : _tokens = 10, _lastRefill = DateTime.now(); bool tryAcquire() { final now = DateTime.now(); final elapsed = now.difference(_lastRefill).inSeconds; if (elapsed >= 1) { _tokens = (maxTokens + elapsed).clamp(0, maxTokens); _lastRefill = now; } if (_tokens > 0) { _tokens -= 1; return true; } return false; } }参数可以按你的实际使用习惯调整。maxTokens = 10表示短时最多连续放行 10 次,refillInterval = 3 秒表示每 3 秒补充一个令牌。这个参数组合对移动端搜索场景够用,翻页时连续加载十几页会出现轻微限流,但用户体验上不是不可接受。如果想要更平滑,可以把maxTokens加到 20,但我不建议更大,因为服务器端的阈值不会无限放宽。
令牌桶代码本身没有依赖第三方库,直接放在网络层里,每次apiGet调用前先检查一次。如果tryAcquire()返回 false,就把请求往后推迟,而不是硬发出去。
3. 把站点数据搬进手机:帖子流、标签搜索与收藏
3.1 帖子流与翻页:用 page 游标做手机上拉加载
e621 的帖子列表接口返回的是一个帖子数组,移动端做无限滚动时最常见的设计是用 page 参数做分页。每次请求新的 page,把返回的帖子追加到列表末尾。这里有一个容易出错的地方:并发请求。用户快速上拉时,界面可能会同时触发第 2 页和第 3 页的加载,数据到达顺序不确定,就会重复插入。
我一般会用一个isLoading标志位拦截,只有上一次请求完成之后才能发起下一次。代码结构大致是这样:
// 帖子列表加载:每次只允许一个分页请求在跑 Future<List<Post>> fetchPostPage(int page, List<String> tags) async { if (_isLoading) return []; _isLoading = true; try { final params = { 'tags': tags.join(' '), 'page': page.toString(), 'limit': '40', }; final res = await apiGet('/posts.json', params); final data = jsonDecode(utf8.decode(res.bodyBytes)); return data['posts'] .map<Post>((e) => Post.fromJson(e)) .toList(); } finally { _isLoading = false; } }这段代码有两个关键点:_isLoading是实例变量,它保证同一时间只有一个分页请求;limit: 40是单页数量,移动端一次加载 40 条比较平衡,加载太少会频繁翻页,太多则首屏时间变长。
返回结构里每个帖子对象都包含文件 URL、预览图 URL、评分、标签列表、发布时间。移动端渲染的时候,优先展示预览图,点开大图时再到详情页加载原图。不要把原图 URL 直接塞进列表页的 Image widget,否则移动网络下会卡顿到没法用,手机流量也会被瞬间吃完。
3.2 标签自动补全:搜索框的手感和服务器字典联动
图片站用户最常做的事情就是按标签搜索,比如同时搜species:canine和rating:safe。手动输入标签容易打错,所以搜索框一定要做自动补全。e621 提供了标签自动补全接口,常见路径是/tags/autocomplete.json,参数名在不同版本里略有差异,有的是search,有的是search[name_matches],开发时要对着文档确认一次。
客户端不要每次键盘输入都发请求,那会把限流额度快速消耗光。我会用 300 毫秒防抖,用户停顿下来才开始请求,并且把长度不足 2 个字符的输入直接忽略。
// 标签自动补全:防抖 + 最小字符限制 Timer? _debounce; void onTagQueryChanged(String raw) { _debounce?.cancel(); final query = raw.trim(); if (query.length < 2) return; _debounce = Timer(const Duration(milliseconds: 300), () async { final res = await apiGet('/tags/autocomplete.json', {'search': query}); final data = jsonDecode(res.body) as List; tagSuggestions.value = data.take(10).toList(); }); }逻辑上用的是Timer做防抖,取消上一次未执行的请求后,再发起新的请求。为什么取前 10 条?移动端键盘弹起后屏幕空间有限,下拉框展示 10 条刚好一屏,再多就要滚动,交互太重。300 毫秒是输入停顿的普遍阈值,再长会觉得补全迟钝,再短就会频繁打断输入。
3.3 收藏管理:远端收藏与本地数据库的双向映射
收藏是这个移动应用最核心的闭环。用户浏览帖子时点收藏,应用要立刻在 UI 上更新状态;同时还需要定期和远端同步,因为用户可能在网页端也收藏了同一张图。
常见做法是拉取收藏时读取远端收藏列表,拿到帖子 ID 后写入本地 SQLite 表,下次启动时先读本地缓存,再后台刷新远端。这样避免了每次打开应用都要等网络请求。
-- 本地收藏表:以帖子 ID 为主键,站点字段用于区分 e621 与 e926 的同一帖子 CREATE TABLE IF NOT EXISTS favorite ( post_id INTEGER NOT NULL, site TEXT NOT NULL DEFAULT 'e621', created_at TEXT, PRIMARY KEY (post_id, site) );这张表里最容易被忽略的是site字段。e621 和 e926 同一张帖子可能共享同一个 ID,但两个站点对文件的分辨率、过滤和可见性处理不同;如果不加站点区分,用户可能在 e926 收藏了一张图,切到 e621 时同一 ID 显示成另一张图,很容易引发困惑。互补的做法是每次收藏时把来源站点写死,这样切换域名不会影响收藏列表。
4. e1547 避坑记录:开发 e621 + e926 客户端时我踩过的 4 个真实坑
4.1 没有 User-Agent 的请求,一上来就是 HTTP 403
现象:客户端第一次连服务器,请求帖子列表时很快就遇到 403。控制台打印响应体,没有业务错误信息,只有一行提示服务端拒绝了这次请求。
原因:e621 对 User-Agent 极其严格。没有 UA、UA 为空串、或者长度太短都会被挡在入口层。很多移动开发框架自带的 HTTP 客户端会默认发送一段通用的 UA,但那段 UA 并不满足图片站的要求。
解决:统一在网络层强制设置 UA,格式必须包含应用名、版本号、平台信息和联系方式,比如e1547/0.1.0 (Android; contact: dev@example.com)。同时注意不要在业务层覆盖这个 Header,避免调试时又被自己人改回去。
4.2 页面滚得快一点就返回 501 限流,别把它当服务器故障
现象:列表页快速上拉时,前几分钟一切正常,翻了十几页之后接口开始返回 501。按照经验判断是频率超标,但明明每次请求间隔都超过 2 秒。
原因:请求间隔的“单请求”视角只是表象。翻页时应用自动触发了原图预加载,图片资源和 JSON 请求同时发出,令牌桶里积攒的配额很快被一次突发请求耗尽。多请求并发导致实际请求数大于预估。
解决:把预加载和分页请求做统一限流,或者干脆把并发下载队列拆开。我一般会给图片下载单独设一个并发阈值为 2 的队列,而 JSON 请求走另一条路径。两条通路互不争抢,限流判断也更有底气。如果服务器依然返回 501,客户端要退避等待至少 3 秒再重试,不要立即重发。
4.3 文件名正确,图片在下次打开时黑屏
现象:收藏过的帖子,文件下载到本地时文件名是正确的,但过几天再从收藏夹打开,图片区域只显示黑色块。重新下载又可以显示,过几天又黑屏。
原因:缓存主键设计漏了站点维度。e621 和 e926 对同一个帖子可能提供不同的文件地址,我使用帖子 ID 做缓存文件名,结果在 e926 下载的文件被 e621 的帖子 ID 映射覆盖。文件内容格式出问题后,显示端直接渲染失败。
解决:缓存和收藏一样,都要把文件名与“站点 + 帖子 ID”绑定。我改成了${site}_${postId}.jpg的方式,同时把文件实际格式信息写到 meta 文件里,显示端先读 meta 再按格式加载。这个改动看起来小,但避免了大量黑屏投诉。
4.4 切换到 e926 后搜索结果“空了一截”,不是 Bug,是过滤在起作用
现象:用户从 e621 切到 e926 之后,同一个标签搜索词返回的帖子明显变少,有的搜索词甚至一页都没有。
原因:e926 在服务端做了评分过滤,安全评分以下的帖子直接不返回。这不是 Bug,而是两个站点的机制差异。但用户并没有心理准备,容易以为应用坏了。
解决:客户端要感知当前站点的评分基线。当搜索结果数量极少或为空时,在页面底部显示一条提示,说明“当前为 e926 安全站点,已过滤高评分内容”。不要硬把空列表渲染成数据为零,要给用户一个明确的行动暗示,比如切换到 e621 继续查看。这个提示文案不需要复杂,但一定要存在。
4.5 登录态失效:收藏、投票时冷不丁弹出重新认证
现象:用户收藏第一张图时正常,连续收藏五六张后突然收到 401。旧版应用直接报错,没有引导用户重新登录。
原因:服务端对 API Key 的校验会随着权限更新或账号状态变化而失效,不是做一次登录就能一劳永逸的。移动端没有网页端 Cookie 那么长的生命周期,需要主动管理登录凭证。
解决:在认证失败时拦截 401 响应,弹出一次性页面引导用户重新进入账号设置页,输入用户名和新的 API Key。不要把旧 Key 继续存着反复试,浪费请求次数。同时可以本地保留一份上次成功同步的收藏快照,在用户重新登录之前,阅读体验不受影响。
5. 进阶可复用的小技巧:为 e1547 做一个“按热度掉落”的推荐流
5.1 用 order:score 和时间窗组合出每日推荐
只依赖用户手动搜索会让应用显得很被动。我习惯在应用里加一个“热门”入口,实现起来不需要单独做一个推荐系统,而是用搜索参数堆出来的。核心是order:score配合时间窗,限定当天上传的帖子按评分排序。
// 每日热门:当天上传、评分超过 50 的帖子按热度排序 String hotQuery() { final now = DateTime.now(); final startOfDay = DateTime(now.year, now.month, now.day); return 'order:score score:>=50 uploaded:>=${startOfDay.millisecondsSinceEpoch ~/ 1000}'; }这段查询逻辑的关键是把uploaded设为当天零点的时间戳,再配合score:>=50过滤掉冷门内容。实际效果不是真正的个性化推荐,但比完整推荐系统简单得多,而且不会额外消耗接口配额。移动应用开发里这种“用参数组合替代算法”的做法很常见,维护成本低,效果也直观。
5.2 离线收藏与下载队列:给二次打开一颗后悔药
我喜欢在实现收藏的同时做一个批量缓存队列。用户收藏帖子后,应用自动把原图加入下载队列,但不要一下子全塞进去,否则限流和手机电量都会出问题。控制并发在 2 个任务左右,等一个完成再拉下一个。这给用户带来一个很实在的好处:没有网络时也能打开收藏看缓存。
最后的收尾习惯是:每次发布前我会在真机上把网络切到 4G 环境,完整走一遍搜索、翻页、收藏、切换站点再切回来的流程。这个流程能暴露很多模拟器里发现不了的问题,比如 UA 头被系统组件改写、分页并发触发限流、缓存文件因站点切换导致黑屏。把这些问题全部打掉之后,这个移动应用才算真正能交到用户手里。希望帮到你。
本文还有配套的精品资源,点击获取