Folo Mobile v0.4.1 发布全解析:iPad 布局适配、iOS 订阅管理简化与滚动已读时序修复
Folo(@follow/mobile)是「AI RSS 阅读器」的跨端移动客户端,基于 Expo / React Native 构建。本文以仓库内发布的 v0.4.1 变更记录(apps/mobile/changelog/0.4.1.md)为主体,逐一还原该版本在 iPad 布局、文章详情渲染、iOS 订阅管理、认证与设置体验、SDK 兼容对齐上的改进,以及「登录后时间线不刷新」「下拉刷新后误标已读」「EAS 云端构建缺依赖」三个真实缺陷的修复原理。通过对照源码与提交记录,你可以了解 Folo 移动端在响应式布局、WebView 内容渲染、IAP 事务校验、会话级 React Query 缓存管理等方向的具体工程做法,并可直接将其中的设计迁移到自己的 React Native 项目中。
版本背景:从 v0.4.0 到 v0.4.1
根据 git 标签信息,v0.4.0 合并于 2026-03-12(mobile/v0.4.0),v0.4.1 合并于 2026-04-03(mobile/v0.4.1),两者之间经历约三周迭代。v0.4.0 引入了 Apple 订阅、免登录匿名时间线、应用内评分与法语本地化等大功能(见 apps/mobile/changelog/0.4.0.md),而 v0.4.1 属于典型的体验加固与缺陷修复版本,不新增功能,重点解决 v0.4.0 遗留下来的一批真机/跨平台问题。
v0.4.1 变更记录共分三类:
| 分类 | 内容 |
|---|---|
| Improvements | iPad 布局与文章详情渲染、iOS 订阅管理简化、认证与设置 UX 打磨、Discover/Settings 的 SDK 兼容对齐 |
| No longer broken | 登录后时间线刷新、下拉刷新后已读标记时机、EAS 构建依赖(补 expo-network) |
| Thanks | 感谢志愿者贡献者 |
下文将按此骨架展开,并给出对应提交记录(短哈希)与当前源码路径作为佐证。
改进一:iPad 布局适配与文章详情渲染
对应提交:
a2a88d9fdfix(mobile): improve ipad layouts and detail rendering(2026-03-13)
平板布局判定与「可读宽度」容器
这一轮改进的核心是引入一套「以窄屏为基准、在宽屏上约束内容宽度并居中」的响应式方案,工具集中在 apps/mobile/src/lib/responsive.ts:
export const useIsTabletLayout = () => {
const deviceType = useDeviceType()
const { width, height } = useWindowDimensions()
if (!isIOS) return false
return deviceType === DeviceType.TABLET || Math.min(width, height) >= tabletMinLength // 744
}
要点:
tabletMinLength = 744(responsive.ts),即最小边 >= 744 即按平板布局处理;Android 上暂不启用该判定(if (!isIOS) return false),因此 iPadOS 分屏、大屏 iPhone 也会受益;- 宽屏下用
useReadableContainerStyle(maxWidth, gutter)生成一个width: "100%"; maxWidth; alignSelf: "center"的样式对象,把行宽约束在可读范围。
从提交统计看,本次改动波及 32 个文件、约 +456/−191 行,覆盖列表视图、分组列表、登录页、订阅列表等,例如:
- EntryListContentArticle.tsx 中文章流用
useReadableContainerStyle(860, 16)包裹每条 entry; - GroupedList.tsx(设置页分组卡片)依据
useIsTabletLayout()决定是铺满还是窄栏; - 登录页在平板下居中显示、在窄屏下顶格显示(见下文认证 UX 部分)。
WebView 内文章详情渲染:从「绝对定位」到自适应排版
iPad 上另一主要痛点是文章详情 WebView 的排版与高度。修复点有两处:
-
原生侧注入 CSS 收紧内容排版(apps/mobile/native/ios/Modules/SharedWebView/FOWebView.swift):FOWKWebViewConfiguration 注入的样式从简单三行扩展为对
body / #root / article及其子块(p、div、ul、ol、pre、table、blockquote、h1~h6、figure)统一width:100%; max-width:100%; box-sizing:border-box,并让图片、figure 水平居中,避免详情内容在宽屏上被绝对定位撑破。 -
RN 侧按 entryId 重置容器高度(EntryContentWebView.tsx):
useEffect(() => {
// Reset the shared container height before the next entry content arrives.
setContentHeight(Dimensions.get("window").height)
}, [props.entryId, props.showReadability, props.showTranslation, setContentHeight])
同时视图的 key 由 mode 改为 `${mode}-${props.entryId}`,WebView 自身样式补上 width:"100%", height:"100%"(StyleSheet 中 webView),确保切换条目时旧高度/旧内容不会串场,列表与详情共享的 sharedWebViewHeightAtom 高度被正确重置。
从结构看,Folo 的文章详情采用共享 WebView 实例(
sharedWebViewHeightAtom+WebViewManager)以支持音频播放、图片灯箱、翻译/可读性模式切换;这次修复保证了多条目快速切换与宽屏下的稳定性。
改进二:iOS 订阅管理简化
对应提交:
d27261d76fix(mobile): simplify iOS subscription management (#4947,2026-04-02)
移除「跳转外部网页管理订阅」路径
旧实现中,若当前订阅源为 Apple,点击「Manage Subscription」会调用 openSubscriptionManagement(),将用户引导到 apps.apple.com/account/subscriptions 网页,并在失败时 toast 报错。v0.4.1 移除了这条路径:
- AppleIAPProvider.tsx 删除了
APPLE_SUBSCRIPTION_MANAGEMENT_URL常量、openSubscriptionManagement上下文字段及showManageSubscriptions/openURL依赖; - Plan.tsx 的
handleManageSubscription不再区分source === "apple",统一走 Stripe 的billingPortalMutation(账单门户)。Apple 订阅在 UI 上改为静态提示而非可点击按钮:
isAppleSubscription ? (
<Text ...>{t("plan.manage_subscription_hint_apple")}</Text>
) : (
<Pressable ... onPress={onManageSubscription} ...>...</Pressable>
)
对应英文文案位于 locales/settings/en.json:"plan.manage_subscription_hint_apple": "Manage: Apple Account > Subscriptions",即引导用户到系统「Apple 账户 > 订阅」中自助管理,符合 Apple 审核对应用内订阅管理的限制。
Provider 重构:统一的计费状态刷新
同一提交还重构了 AppleIAPProvider 的状态刷新逻辑,把原先组件内的 refreshBillingState 提升为模块级函数,统一完成三件事(当前代码见 apps/mobile/src/providers/AppleIAPProvider.tsx):
const refreshBillingState = async () => {
await Promise.allSettled([
userSyncService.whoami(),
queryClient.invalidateQueries({ queryKey: whoamiQueryKey }),
queryClient.invalidateQueries({ queryKey: billingSubscriptionQueryKey }),
])
}
购买完成、事务结束、恢复购买后都调用它,使套餐页(billingSubscription 查询)、用户角色(whoami)与服务器端订阅状态保持一致。isRestoring 状态被保留用于「恢复购买」按钮的 loading 展示;事务校验仍然使用 expo-iap 的 JWS(getTransactionJwsIOS / selectSignedTransactionInfo)上送服务器验证,保证 IAP 凭证可信。
同期(
44614f746fix(mobile): show stripe subscriptions on android)还修正了 Android 端订阅列表只渲染 Apple 源的问题,使 Stripe 订阅能正确显示在套餐页中。
改进三:认证与设置体验打磨
对应提交:
df83cf62efix(mobile): polish auth and settings ux(2026-03-13)
登录页:滚动容器 + 键盘体验 + 平板适配
登录/注册页在 apps/mobile/src/modules/login/index.tsx 中做了重构:
- 原先依赖
justify-between+ 绝对底部布局,小屏与键盘弹出时易被遮挡;改为KeyboardAvoidingView behavior="position"内嵌ScrollView,并显式设置keyboardDismissMode="on-drag"、keyboardShouldPersistTaps="handled",拖动即收键盘、点击输入框外的按钮不会丢失首次点击; - 顶部间距用
insets.top + 56(含安全区)与paddingBottom: insets.bottom + 24保证刘海屏/Home 指示条不被内容遮挡; - 平板适配:
useIsTabletLayout()为 true 时页面垂直居中、水平约束到 480 的可读宽度,并把「协议勾选 + 切换登录/注册」按钮组放到内容下方而不是挤在底部;窄屏下仍保持原来的两段式布局。
设置与账号安全:全量接入 i18n 与统一交互
同一提交替换了设置区的大量硬编码文案与原生弹窗:
- SettingsList.tsx:退出登录确认框改用
Dialog.show+i18next.t("profile.sign_out.confirm_title"...),消除硬编码字符串; - 2FASetting.tsx:双因素认证(TOTP)设置页的标题、扫码说明、校验成功/失败 toast 全部本地化,并保留
react-native-otp-entry的 6 位验证码输入; - Account.tsx:第三方账号绑定/解绑从
Alert.alert+ 裸文案迁移到带 i18n 的确认流程;修改密码、开启/关闭 2FA 的 toast 与错误提示统一走t("profile.*")。
新增文案沉淀在 locales/mobile/default 与 locales/settings 的 en / ja / zh-CN / fr-FR 等文件中,为 0.4.0 引入的法语等多语言提供完整覆盖。
改进四:Discover 与 Settings 的 SDK 兼容对齐
对应提交:
1571396fbfix: upgrade sdk packages and align app types (#4950,2026-04-02)
该提交一方面升级了 SDK 相关包(涉及 pnpm-lock.yaml 大范围变更),另一方面对齐了升级后可能出现的类型漂移与 API 契约变化,其中与移动端 Discover / Settings 相关的关键点:
- apps/mobile/src/modules/discover/api.ts:
fetchRsshubAnalysis移除lang参数——服务器端 RSSHub Analytics 不再按语言过滤,改为空参请求(rsshubAnalytics({}));同步更新 Recommendations.tsx 的调用方,并把结果缓存staleTime设为 1 天(RSSHub 分析数据是低频更新); - constants/views.tsx:Feed 视图定义的类型由宽松的
React.FC<...>收敛为显式导入的FC<...>,并用结构分解{ icon: _icon, ...restView }去掉冗余 icon 后再合并视图扩展,使类型推断与升级后的@follow/constants保持一致; - 设置相关路由如 EditRewriteRules.tsx 为
rewriteRule补充了{ from: string; to: string }的显式类型标注,消除升级后 map 回调中的隐式 any 风险。
这类改动通常不产生可见 UI 差异,但它修复了「Discover 分类加载异常」与「设置项在升级包后行为漂移」等潜在问题,也正是该版本将之列入 Improvements 的原因。
修复一:登录后时间线不刷新
对应提交:
bd0d081a7fix(mobile): refresh timeline after login(2026-03-17)
根因:匿名会话缓存残留
Folo 支持 v0.4.0 引入的免登录匿名浏览。匿名时 entries / subscription / unread 等查询已有缓存;用户登录后,旧的匿名缓存仍挂在已挂载的时间线组件上,导致界面看起来「没反应」。
修复:会话级查询在认证跃迁时整体重置
当前实现位于 apps/mobile/src/lib/auth.ts:
const refreshSessionQueries = () =>
Promise.allSettled([
queryClient.invalidateQueries({ queryKey: whoamiQueryKey }),
queryClient.invalidateQueries({ queryKey: isNewUserQueryKey }),
queryClient.resetQueries({ queryKey: ["entries"] }),
queryClient.resetQueries({ queryKey: ["subscription"] }),
queryClient.resetQueries({ queryKey: ["unread"] }),
queryClient.resetQueries({ queryKey: ["owned", "lists"] }),
queryClient.resetQueries({ queryKey: ["action", "rules"] }),
])
关键点:
- 代码注释明确说明动机:
Session-scoped queries must be reset on auth transitions, otherwise mounted timeline queries can keep rendering anonymous cache under the new session.; - 认证凭据被写入(
setItem)时,若之前不存在 cookie(即新登录),则触发重置;被删除(removeItem/登出)时若之前存在 cookie 也触发重置——用「前后值对比」精确判断真正的登录态跃迁,避免重复刷新; - 登出路径
signOut()同样调用refreshSessionQueries()后再删除本地 SQLite 数据库并reloadAppAsync; - 通过统一的
bumpAuthStateRevision()维护认证版本号,供 UI 感知认证状态变化。
时间线数据流因此变为:登录 → cookie 写入 → entries/subscription/unread 重置 → 以新会话身份重新拉取,完成「登录即刷新」。
修复二:下拉刷新后立即误标已读
对应提交:
ce5e91962fix: pause scroll mark read after refresh (#4946,2026-04-02)
问题现象
Folo 支持「滚动时把滑过的条目标记为已读」(scrollMarkUnread / renderMarkUnread 设置)。下拉刷新后,列表内容被替换、offset 回弹,旧条目与新条目在同一帧内被判定为「滚出可视区」,触发 queueEntriesAsRead 误标已读,用户往往来不及看到新内容。
修复:引入滚动已读的「宽限期」hook
新增共享 hook packages/internal/hooks/src/useScrollMarkReadGracePeriod.ts(默认宽限期 1000ms,默认值常量 DEFAULT_GRACE_PERIOD_MS = 1000):
export const useScrollMarkReadGracePeriod = (refreshing, gracePeriodMs = 1000, pauseKey?) => {
// refreshing 为 true → 立即暂停
// refreshing 由 true → false 时,不清零而是先 pauseForGracePeriod()
// 宽限期结束且 refreshing 仍为 false,才恢复滚动已读
}
实现要点:用 wasRefreshingRef 记录上一次值;当 refreshing 结束时不立即放行,而是进入 pauseForGracePeriod(),经 1s 后若已不再刷新才恢复,从而避开回弹窗口期的误判;额外支持 pauseKey(任意标识)主动暂停,供其他需要「先于列表替换完成」的场景复用。由于该 hook 位于 @follow/hooks 共享包,桌面端同样受益。
落地到移动端列表
在 apps/mobile/src/modules/entry-list/hooks.ts 的 useOnViewableItemsChanged 中接入:
const pauseScrollMarkRead = useScrollMarkReadGracePeriod(refreshing ?? false)
...
if (pauseScrollMarkRead) {
setLastRemovedItems(null)
return // 不采集移除项,也就不会在刷新回弹期间标记已读
}
随后只有在 !pauseScrollMarkRead 且满足向下滚动、offset 为正(避开下拉回弹负偏移)等条件时,才把滑出可视区的条目交给 unreadSyncService.queueEntriesAsRead 批量提交。若用户开启「渲染即已读」(markAsReadWhenRendering),同样受该宽限期保护。
修复三:EAS 构建缺依赖(补 expo-network)
对应提交:
1c89c8476fix(mobile): add expo-network for EAS builds (#4951,2026-04-02)
EAS(Expo Application Services)云端构建需要应用显式声明运行时依赖才能正确执行 autolinking。v0.4.1 修复了构建依赖清单问题——向 apps/mobile/package.json 添加了 "expo-network": "57.0.0"(与当前仓库一致),并相应更新 pnpm-lock.yaml。
配套的 EAS 配置(apps/mobile/eas.json)里区分了 production / preview / e2e 等 profile(含 channel 与 PROFILE 环境变量),并由 scripts/apply-release-config.impl.ts 校验 channel 只能取 production 或 preview;package.json 中还预置了 eas-build-pre-install(pod repo update)与 eas-build-post-install(清理 metro 缓存)钩子,保证云构建环境的 CocoaPods 与打包缓存处于一致状态。expo-network 进入依赖清单后,构建阶段即可稳定链接该原生模块,避免云端构建与本地开发环境的依赖差异。
结语与工程启示
v0.4.1 虽是小版本,却浓缩了移动端三类典型工程问题的解法,可在其他 RN 项目中直接复用:
- 响应式:不要用「屏幕是否很大」的魔法数,而是封装
useIsTabletLayout()判定 +useReadableContainerStyle(maxWidth)约束内容宽度(本项目阈值 744 / 860 / 480 可作参考); - 会话缓存一致性:当产品支持匿名态,凡涉及登录/登出的数据查询必须整体
resetQueries(而非仅invalidateQueries),并把需要重放的 queryKey 集中管理,如 auth.ts 的refreshSessionQueries; - 滚动副作用:所有「滚动即写」的副作用(标已读、埋点、预取)都应预留宽限期与方向/offset 条件,避免刷新、插入、回弹造成的误触发,参考 useScrollMarkReadGracePeriod.ts。
如果你想继续追踪后续演进,可对照 0.4.2 之后各版本的 changelog,以及移动端总入口 App.tsx 与构建配置 app.config.base.ts 一窥整体架构。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0631
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00