简介这是一款基于 Flutter/Dart 构建的移动端应用源码专门用于浏览 e621 与 e926 内容为关注此类社区的移动用户提供搜索、浏览、评论、修改、上传下载、收藏与标签追踪等完整能力。面向希望学习 Flutter 跨平台开发的开发者尤其适合对第三方 API 客户端、多媒体解析与自定义主题有一定进阶需求的读者。压缩包共 185 个文件约 29.59MB核心代码以 108 个 dart 文件为主涵盖 API 客户端、DText 解析、帖子详情与设置等模块32 个 png、7 个 jpg 与 2 个 gif 构成界面与预览资源其余为 Android/iOS 工程配置gradle、plist、storyboard及项目元数据结构清晰便于按模块阅读。已有 3124 人浏览/学习下载后可获得完整工程源码既可直接编译运行至 Android/iOS 设备也可重点研究 e621 API 封装、DText 富文本解析、视频播放、本地黑名单与多主题切换等实现对理解 Flutter 实际商业级应用组织方式有较好参考价值。1. e1547 是给 e621 e926 做的移动端壳子先分清镜像站再动手e1547 这个编号看起来像竞赛题号或者仓库代号但落到实操它讲的是把 e621 和 e926 这两个站点的浏览体验搬进手机。e621 是 Danbooru 系图站e926 是它的 SFW 镜像内容同源但两个站的域名、分级规则和 API 策略完全不同。做这个移动应用核心不是 UI 多漂亮而是把两套 host、UA 鉴权、rating 分级和 CDN 校验摸透否则列表页能刷出来点进大图就 403收藏帖子还会 401。这个方向很适合拿来练移动应用开发或参加技能大赛它是标准的“不复杂但极易翻车”的工程逼你处理真实 API、鉴权、内容合规和限速而不是停在写死数据的 demo。我按自己做过的一套方案往下拆把选型和踩坑摊开讲走完你也能做出一个可上架的客户端。2. 把 e621/e926 的 API 与认证摸清Host、User-Agent 和限速档位2.1 两个站点共用同一套底层但 Host 和 Rating 边界不能混e621 和 e926 在架构上是一对“主站 内容过滤镜像”。底层帖子库、标签体系、收藏关系都共享但对外提供的 API Host 完全不同api.e621.net是主接口api.e926.net是 SFW 镜像接口。移动端如果只做一个适配最省事的做法不是把两个站都塞进同一个 Host 配置而是把 baseUrl 做成可切换的常量并且让请求层强制绑定当前站点。为什么要分开两个站在三个维度上不一致。第一是认证上下文e621 的账号 token 到了 e926 不一定被认可确切说是两个站点对同一账号的 API Key 授权状态未必同步第二是 rating 边界e621 会返回 safe、questionable、explicit 三档内容e926 只返回 safe第三是 CDN 地址图片资源分别挂在static1.e621.net和static1.e926.net下URL 不能互相指。站点API Host图片 CDN返回内容评级e621api.e621.netstatic1.e621.netsafe / questionable / explicite926api.e926.netstatic1.e926.net仅 safe做客户端的时候我习惯在工程里先定义两个常量组class SiteConfig { final String apiHost; final String cdnHost; final ListString allowedRatings; const SiteConfig({required this.apiHost, required this.cdnHost, required this.allowedRatings}); static const e621 SiteConfig( apiHost: https://api.e621.net, cdnHost: https://static1.e621.net, allowedRatings: [s, q, e], ); static const e926 SiteConfig( apiHost: https://api.e926.net, cdnHost: https://static1.e926.net, allowedRatings: [s], ); }这段代码把站点差异约束在一个对象里后续所有请求都从当前 SiteConfig 取值。allowedRatings在解析帖子时用来做第一层过滤既能防止 e926 模式下意外展示 q/e 内容也能在 e621 模式下按用户偏好跳过某个评级。把配置集中写的另一个好处是如果哪天 e621 换了 CDN 域名只改这一处就能全局生效。两个站点的搜索词、收藏列表、热门榜都可以走同一套分页逻辑唯独 Host 不能混用。2.2 请求头与认证User-Agent 是最低门槛API Key 比 OAuth 更省事e621 和 e926 对请求有一个硬性要求请求头必须带合法的 User-Agent格式类似应用名/版本 (by 你的e621用户名)。不带 UA、或者 UA 长得像普通浏览器接口会拒绝甚至直接断连。这个校验对 API 和图片 CDN 同时生效所以不要在请求层忽略它。服务端之所以强制校验是因为这类社区站点常年被爬虫骚扰靠 UA 区分正常客户端和批量脚本是最基础的一层。认证方式有两种。OAuth 2.0 适合做完整账号功能比如收藏同步、自定义黑名单、私信但如果你的应用只是浏览加搜索一份 API Key 就够了。API Key 在账号设置页可以生成请求时放在 Authorization 头用 Basic 方式把用户名:api_key做 Base64 编码即可。我一般在 Dio 初始化时用拦截器统一处理这两件事import package:dio/dio.dart; import package:shared_preferences/shared_preferences.dart; Dio buildDio(SiteConfig site) { final dio Dio(BaseOptions( baseUrl: site.apiHost, connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 20), )); dio.interceptors.add(InterceptorsWrapper(onRequest: (options, handler) async { final prefs await SharedPreferences.getInstance(); final username prefs.getString(username) ?? anonymous; final apiKey prefs.getString(api_key) ?? ; options.headers[User-Agent] e1547/0.1.0 (by $username on e621); if (apiKey.isNotEmpty) { final raw $username:$apiKey; options.headers[Authorization] Basic ${base64Encode(utf8.encode(raw))}; } handler.next(options); })); return dio; }这段代码做了三件事统一拼接合法 UA有 API Key 时自动加认证头把超时时间调到移动网络可用的范围。connectTimeout设 10 秒是因为弱网环境下信号切换频繁太短会误报超时receiveTimeout设 20 秒是因为查大标签集合时服务端响应慢但这个保护只针对单次请求不保护整体数据量。认证失败时服务端通常返回 401 表示未认证403 表示 UA 或权限被拒排查方向完全不同日志里要把状态码和响应体一起记下来。提示把 API Key 放进 SharedPreferences 只是开发期方案。正式打包时建议改用系统级安全存储Android 用 EncryptedSharedPreferencesiOS 用 Keychain避免明文 key 随备份泄露。2.3 分页与限速page 参数别乱用limit 最高 320帖子列表接口是/posts.json最关键的参数是tags、limit和分页。limit最大能开到 320传超过 320 不会报错但只会给上限所以我一般直接用 320 减少请求次数。分页有两种方式老式的整数页码page1以及基于游标的pageb帖子id。整数页码在帖子总数变化时会重复或漏帖收藏夹和热门榜这类动态列表要避开它。我的请求封装长这样FutureListdynamic fetchPosts(SiteConfig site, { String tags , String? pageCursor, int limit 320, }) async { final dio buildDio(site); final res await dio.get(/posts.json, queryParameters: { tags: tags, limit: limit, if (pageCursor ! null) page: pageCursor else page: 1, }); return (res.data as Map)[posts] as Listdynamic; }代码里pageCursor优先于整数页码。第一页不传游标服务端会在响应 meta 里给出下一页游标后续请求直接把上一页拿到的游标原样塞回去。这种做法比“当前页码加 1”稳定得多因为服务端列表按 id 排序数据一直在变整数页码计算的是偏移量游标计算的是“从哪个 id 继续”后者不会重复也不会跳过。限速方面别背网上流传的硬数字直接看响应头里和x-rate-limit相关的字段服务端会告诉你当前剩余次数和重置时间。客户端要做的是把剩余次数打日志当剩余低于阈值时把并发预加载改成串行。很多限速翻车不是一次请求触发的是列表页一次性并发 20 张图片额度瞬间打满。3. 移动端数据层选型与搜索把 e621 的标签语法搬进 App3.1 为什么用 Flutter Dio移动应用开发的参赛与上架平衡技术选型没有绝对答案我做完一轮比较后选了 Flutter。原因有三单套代码覆盖 Android 和 iOS两个应用商店的适配工作量减半Dio 拦截器体系正好匹配 e621 这种需要在请求层统一加 UA/认证的场景图片缓存用 cached_network_image 可以带自定义请求头解决 CDN 校验问题。如果你在准备移动应用开发技能大赛Flutter 也是比较稳妥的选择赛题常用跨端框架调试时不用在两台真机间反复传导。原生双端不是不行但同样的代码要在 Kotlin 和 Swift 里各写一遍请求拦截、OAuth 回调和内容过滤等于把踩坑次数翻倍。对于 e1547 这种“API 细节比 UI 复杂”的项目少一套语言的成本优势很明显。另一个加分项是 Flutter 的调试体验DevTools 里直接看 Dio 请求耗时和响应头比在原生里打断点看拦截器方便很多。一个容易忽略的点Dio 默认会把 query 参数按 Map 的遍历顺序拼进 URL而 e621 的搜索不依赖标签顺序所以顺序无关。但千万不要自己手工拼 URL 字符串。把tags传给 Dio 的queryParameters后它会帮你处理空格和中文标签的 URL 编码如果手拼tags...漏掉一个%20就可能整页报错。重试策略我也习惯在拦截器里做对 429 和 5xx 各重试一次间隔指数退避但要给重试次数设上限避免弱网下无限循环。3.2 搜索语法解析空格是 ANDOR 是大写排除用减号e621 的搜索框语法是通用 Danbooru 标签语法不做解析直接透传也能跑但移动端用户的输入习惯会搞坏它输入法自动把中文逗号、英文逗号混在一起服务端标签解析器不认这些符号。所以必须自己做一层清洗。我写的解析函数处理四件事去掉多余空白中文逗号转空格保持-tag前缀符号不被拆分把OR保留为大写逻辑符。ListString tokenizeE621(String input) { final cleaned input .replaceAll(, ) .replaceAll(,, ) .trim(); return cleaned .split(RegExp(r\s)) .where((t) t.isNotEmpty) .toList(); } String formatE621Query(String userInput) { final tokens tokenizeE621(userInput); final ListString parts []; String currentOr ; for (final t in tokens) { if (t OR) { currentOr OR ; } else if (t.startsWith(-)) { if (currentOr.isNotEmpty) { parts.add((${currentOr.trim()})); currentOr ; } parts.add(t); } else { currentOr $t ; } } if (currentOr.isNotEmpty) parts.add((${currentOr.trim()})); return parts.join( ); }逻辑说明输入fox mage OR wolf -dog会被拆成(fox) (mage OR wolf) (-dog)对应服务端语义是“第一组必须满足第二组二选一第三组必须排除”。为什么要加括号因为服务端把空格解析为 AND如果不加括号mage OR wolf -dog会被理解成 “mage 与 wolf 与 -dog” 的三元组OR 失效。这个坑我是在搜索“cat OR tiger”时发现的不加括号结果集明显变少。参数方面用户输入里可能混进rating:s、score:50这类元标签解析函数不破坏它们原样传给服务端。这样高级用户能继续写完整语法普通用户又能得到键盘容错。另外要注意e621 支持中文标签如果你要面向中文用户不要把标签强制转成 ASCII原样透传就好。3.3 图片加载必须带 UACDN 校验造成的 403 血泪经验这是做 e621/e926 客户端最典型的翻车点列表接口正常返回 JSON图片却全挂控制台一片 403。原因在于图片请求不带 API 层那套 User-AgentCDN 直接拒绝。Flutter 自带Image.network不让你传请求头所以必须换加载方式。我用 cached_network_image 在全局统一处理import package:cached_network_image/cached_network_image.dart; CachedNetworkImageProvider buildCdnImage(String url, SiteConfig site) { return CachedNetworkImageProvider( url.replaceFirst(static1.e621.net, site.cdnHost), headers: { User-Agent: e1547/0.1.0 (by anonymous on e621), Accept: image/webp,image/*,*/*;q0.8, }, ); }headers 里的 UA 要与 API 层保持一致换个 UA 一样被拒。Accept头不是必须的加上后 CDN 会优先回 WebP流量更省。注意url.replaceFirst这行如果列表接口返回的图片地址来自 e621 CDN而当前站点是 e926必须把域名替换成当前站点的 cdnHost否则跨站取图会因站点评级策略不一致偶尔出问题。另一个参数是缓存 keye621 图片 URL 是稳定的 hash 路径不需要额外处理。但如果你给图片加了“强制原图”的逻辑注意统一 key 拼接规则别让同一张图在内存里存两份。清除缓存时也要谨慎只清应用自身缓存目录别动系统图片缓存。4. 内容分级与安全过滤e926 的 SFW 模式如何落到产品设计4.1 rating 三档与镜像站策略Danbooru 系帖子自带rating字段取值是单字符s表示 safeq表示 questionablee表示 explicit。e621 三档都返回e926 只保留s。这意味着你不需要额外做图片识别只需要信任服务端字段并且在请求与展示两层做校验。请求层要注意搜索参数里写rating:e在 e926 模式下大概率得到空列表有时候接口会直接拒绝组合。我在请求封装里对 query 做了约束——检查当前站点是否允许该 rating不允许就在本地拦截并提示“当前站点不支持该过滤条件”而不是把请求发出去等服务端错误。这个拦截能省下很多无谓的限速消耗。展示层的过滤要点是“不只过滤列表还要过滤详情页”。搜索结果里可能混入少量带敏感标签但 rating 为 s 的帖子比如含成人主题讨论的漫画页光看 rating 不够。我习惯把 rating 和标签黑名单同时作为过滤条件只有评级异常或命中黑名单才拦截。questionable这个分级的尺度很模糊很多内容比 explicit 更擦边所以不能只在 e621 模式下默认放行SFW 开关关闭前要有心理预期。4.2 客户端内容过滤默认 SFW 开关与标签黑名单产品设计上移动应用商店对成人内容审核普遍敏感。一般做法是应用默认 SFW 模式只显示 safe 内容想浏览其他评级必须先到设置里手动开启“高级内容”开关并做二次确认。这不是隐蔽手段而是内容分级应用的合规惯例——开关默认关闭用户在知情前提下选择。过滤逻辑放在数据层而不是 UI 层这样列表、详情、收藏页都能复用class ContentFilter { final bool sfwOnly; final SetString blacklist; const ContentFilter({this.sfwOnly true, this.blacklist const {}}); bool allow(MapString, dynamic post) { final rating post[rating] as String? ?? s; if (sfwOnly rating ! s) return false; final tagMap post[tags] as MapString, dynamic? ?? const {}; final allTags String{ ...?tagMap[general] as ListString?, ...?tagMap[species] as ListString?, ...?tagMap[character] as ListString?, ...?tagMap[artist] as ListString?, }; return blacklist.every((b) !allTags.contains(b)); } }这段代码把“评级”和“黑名单”分开处理。sfwOnly拦截评级不等于 safe 的帖子blacklist拦截包含指定标签的帖子。注意标签在 API 返回里是按类型分组的tagMap[general]、tagMap[character]等字段都要展开只查 general 会漏掉角色标签。黑名单在设置页用多行输入框维护每行一个标签存到本地偏好。过滤顺序也有讲究先判断 rating 再判断标签因为 rating 是单字段判断成本低标签判断要展开数组成本高。命中黑名单的帖子在列表里直接不渲染而不是先渲染再隐藏。如果你用无限滚动列表过滤掉帖子后列表长度会变化分页游标要基于过滤前的帖子 id 计算否则会跳过后续内容。4.3 账号登录与收藏同步OAuth 回调在移动端的落地如果应用只做浏览API Key 就够。一旦做收藏同步、踩/赞、评论自己的列表就建议用 OAuth 2.0。e621 的 OAuth 流程是标准授权码模式应用先拼一个授权 URL用户跳浏览器登录确认服务端回调拿到 code再用 code 换 token。移动端关键在回调地址Android 要配置 App Links 或自定义 schemeiOS 要用 Universal Links不然回调后 App 无法唤起。换取 token 的请求一般是POST /oauth/token参数包括grant_typeauthorization_code、code、client_id、client_secret。拿到access_token后后续请求在 Authorization 头用 Bearer 方式带和 Basic API Key 二选一不能同时使用否则服务端优先认其中一个导致另一个失效。Futurevoid exchangeCode(String code) async { final res await dio.post(/oauth/token, data: { grant_type: authorization_code, code: code, client_id: appClientId, client_secret: appClientSecret, }); final accessToken res.data[access_token] as String; final refreshToken res.data[refresh_token] as String?; // 存进安全存储后续拦截器改用 Bearer 头 }OAuth 有两个坑。第一client_secret放在移动包里抓包能翻出来所以不能把它当作机密服务端要配合“重定向 URI 白名单”限制滥用。第二token 过期后的刷新流程很多实现只存了 access_token 忘了 refresh_token导致用户过两天就要重新登录。我一般把两个 token 一起存401 时自动用 refresh_token 换新 access_token再重放一次原请求。5. 避坑e621 e926 双端适配里最常见的 5 个翻车点5.1 现象帖子列表正常点进详情图片全部 403原因API 层加了统一 UA但图片加载组件用的是 Flutter 默认 Image请求头不带 UA被 CDN 识别为异常客户端拒绝。解决全局替换为 cached_network_image并在 headers 里带上与 API 相同的 UA详情页的轮播图也走同一个 Provider不能只改列表页。排查时先开抓包看失败请求的 request headers如果里面没有User-Agent字段问题就锁定在图片加载层。403 响应里一般还带有 CDN 的特征字段可以作为二次确认。5.2 现象收藏夹翻页出现重复帖子翻到最后死循环原因用整数页码做收藏夹分页但收藏列表会随操作变化偏移量分页在数据变化时不稳定。解决改用游标分页。收藏夹接口返回的page字段通常能直接作为下一页参数客户端只需要把“下一页”传给服务端而不是自己计算页数。我在封装里对列表页统一用pageCursor逻辑整数页码只保留给普通搜索。测试翻页时用一个经常变化的标签词连续翻 5 页对比帖子 id 是否出现重复这是最直接的验证方式。5.3 现象搜索框输入中文或逗号后接口报错或结果为空原因输入法把逗号转成中文标点服务端标签解析器不认识另一些情况是 URL 编码不完整空格没有转成%20。解决请求前做 tokenizer把中文逗号替换成空格tags 参数全部交给 Dio 的 queryParameters 编码不手拼字符串。搜索rating:e在 e926 返回空或报错也是同一类问题代码里在请求前根据 SiteConfig.allowedRatings 做本地拦截。这类问题最容易在线上被用户骂因为输入法行为没法控制只能靠解析层兜底。5.4 现象应用商店审核被拒理由是“不适内容”原因应用里保留了 explicit 内容的浏览入口审核员用默认配置点开看到非安全内容。解决发布版的默认 sfwOnly 为 true高级内容开关默认关闭并在应用商店的年龄分级里如实勾选。审核角度要的是“默认内容安全”不是“完全不允许”开关设计成默认关、二次确认同时不要开放匿名直达rating:e的入口基本能过。这里有个细节审核员会在未登录状态下测试所以匿名模式的过滤策略要和登录后一致不能只在账号资料里做偏好。5.5 现象热门榜刷新时请求瞬间打满接口持续 429原因列表和图片并发请求同时发出限速额度被一波消耗完。解决做串行请求队列图片预加载设置并发上限比如同时最多 4 张读取响应头里的限速剩余字段小于阈值时暂停加载。另一个常用后悔药是缓存热门榜一小时内的数据变化不大本地缓存加短 TTL 能避开峰值限速。排查时在请求日志里看 429 出现的时间分布如果集中在同一秒就是并发没控住如果分散可能是 UA 或 key 被服务端加了惩罚权重需要检查是否无意中带了两个认证头。6. 进阶玩法用 WebSocket 做标签订阅实时推送列表、搜索、收藏只是客户端的基础盘。e621 服务端有 WebSocket 端点监听wss://e621.net/ws可以收到新帖通知。做标签订阅推送是我验证这套工程能力最喜欢的方式用户维护一组关注标签服务端一有新帖App 立刻弹本地通知完全不用轮询。import package:web_socket_channel/web_socket_channel.dart; void watchNewPosts(SetString watchTags, void Function(MapString, dynamic) onHit) { final ws WebSocketChannel.connect(Uri.parse(wss://e621.net/ws)); ws.stream.listen((raw) { final msg jsonDecode(raw as String); if (msg[type] ! post:create) return; final post msg[data][post] as MapString, dynamic; final tagMap post[tags] as MapString, dynamic? ?? const {}; final tags { ...?tagMap[general] as ListString?, ...?tagMap[character] as ListString?, }; if (tags.any(watchTags.contains)) { onHit(post); } }); }这段代码监听post:create事件从返回 JSON 里取标签与本地 watchTags 集合做交集。watchTags.contains是 Set 查找常数级复杂度订阅几百个标签不会卡。注意 WebSocket 断线重连移动网络切换会导致 ws 断开要在 onDone 里做指数退避重连退避上限 30 秒。e926 的 ws 端点地址和 e621 不是同一套如果产品需要双站推送建议在主站连接镜像站用轮询降级。订阅推送还可以和 ContentFilter 联动即使标签命中也先过一遍 sfwOnly 和黑名单再发通知避免用户被不想看的内容震醒。我习惯把这条逻辑放在通知服务里而不是 UI 页面里判断。做这类客户端的最后一条经验所有请求统一走 SiteConfig、UA 和限速读取不要为某个页面临时开特殊通道。e1547 这类项目真正值钱的部分不是某个炫酷页面而是数据层边界反复打磨后可复用的稳定结构把第一次失败请求的日志和响应头保留下来以后再调别的社区 API你会发现踩坑速度明显变慢。希望帮到你。本文还有配套的精品资源点击获取