首页
/ Folo Mobile v0.4.1 发布全解析:iPad 布局适配、iOS 订阅管理简化与滚动已读时序修复

Folo Mobile v0.4.1 发布全解析:iPad 布局适配、iOS 订阅管理简化与滚动已读时序修复

2026-09-08 16:41:48作者:尤辰城Agatha

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 布局适配与文章详情渲染

对应提交:a2a88d9fd fix(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 = 744responsive.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 的排版与高度。修复点有两处:

  1. 原生侧注入 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 水平居中,避免详情内容在宽屏上被绝对定位撑破。

  2. 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])

同时视图的 keymode 改为 `${mode}-${props.entryId}`,WebView 自身样式补上 width:"100%", height:"100%"(StyleSheet 中 webView),确保切换条目时旧高度/旧内容不会串场,列表与详情共享的 sharedWebViewHeightAtom 高度被正确重置。

从结构看,Folo 的文章详情采用共享 WebView 实例(sharedWebViewHeightAtom + WebViewManager)以支持音频播放、图片灯箱、翻译/可读性模式切换;这次修复保证了多条目快速切换与宽屏下的稳定性。

改进二:iOS 订阅管理简化

对应提交:d27261d76 fix(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.tsxhandleManageSubscription 不再区分 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 凭证可信。

同期(44614f746 fix(mobile): show stripe subscriptions on android)还修正了 Android 端订阅列表只渲染 Apple 源的问题,使 Stripe 订阅能正确显示在套餐页中。

改进三:认证与设置体验打磨

对应提交:df83cf62e fix(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/defaultlocales/settings 的 en / ja / zh-CN / fr-FR 等文件中,为 0.4.0 引入的法语等多语言提供完整覆盖。

改进四:Discover 与 Settings 的 SDK 兼容对齐

对应提交:1571396fb fix: upgrade sdk packages and align app types (#4950,2026-04-02)

该提交一方面升级了 SDK 相关包(涉及 pnpm-lock.yaml 大范围变更),另一方面对齐了升级后可能出现的类型漂移与 API 契约变化,其中与移动端 Discover / Settings 相关的关键点:

  • apps/mobile/src/modules/discover/api.tsfetchRsshubAnalysis 移除 lang 参数——服务器端 RSSHub Analytics 不再按语言过滤,改为空参请求(rsshubAnalytics({}));同步更新 Recommendations.tsx 的调用方,并把结果缓存 staleTime 设为 1 天(RSSHub 分析数据是低频更新);
  • constants/views.tsx:Feed 视图定义的类型由宽松的 React.FC<...> 收敛为显式导入的 FC<...>,并用结构分解 { icon: _icon, ...restView } 去掉冗余 icon 后再合并视图扩展,使类型推断与升级后的 @follow/constants 保持一致;
  • 设置相关路由如 EditRewriteRules.tsxrewriteRule 补充了 { from: string; to: string } 的显式类型标注,消除升级后 map 回调中的隐式 any 风险。

这类改动通常不产生可见 UI 差异,但它修复了「Discover 分类加载异常」与「设置项在升级包后行为漂移」等潜在问题,也正是该版本将之列入 Improvements 的原因。

修复一:登录后时间线不刷新

对应提交:bd0d081a7 fix(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 重置 → 以新会话身份重新拉取,完成「登录即刷新」。

修复二:下拉刷新后立即误标已读

对应提交:ce5e91962 fix: 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.tsuseOnViewableItemsChanged 中接入:

const pauseScrollMarkRead = useScrollMarkReadGracePeriod(refreshing ?? false)
...
if (pauseScrollMarkRead) {
  setLastRemovedItems(null)
  return  // 不采集移除项,也就不会在刷新回弹期间标记已读
}

随后只有在 !pauseScrollMarkRead 且满足向下滚动、offset 为正(避开下拉回弹负偏移)等条件时,才把滑出可视区的条目交给 unreadSyncService.queueEntriesAsRead 批量提交。若用户开启「渲染即已读」(markAsReadWhenRendering),同样受该宽限期保护。

修复三:EAS 构建缺依赖(补 expo-network)

对应提交:1c89c8476 fix(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 只能取 productionpreviewpackage.json 中还预置了 eas-build-pre-installpod repo update)与 eas-build-post-install(清理 metro 缓存)钩子,保证云构建环境的 CocoaPods 与打包缓存处于一致状态。expo-network 进入依赖清单后,构建阶段即可稳定链接该原生模块,避免云端构建与本地开发环境的依赖差异。

结语与工程启示

v0.4.1 虽是小版本,却浓缩了移动端三类典型工程问题的解法,可在其他 RN 项目中直接复用:

  1. 响应式:不要用「屏幕是否很大」的魔法数,而是封装 useIsTabletLayout() 判定 + useReadableContainerStyle(maxWidth) 约束内容宽度(本项目阈值 744 / 860 / 480 可作参考);
  2. 会话缓存一致性:当产品支持匿名态,凡涉及登录/登出的数据查询必须整体 resetQueries(而非仅 invalidateQueries),并把需要重放的 queryKey 集中管理,如 auth.tsrefreshSessionQueries
  3. 滚动副作用:所有「滚动即写」的副作用(标已读、埋点、预取)都应预留宽限期与方向/offset 条件,避免刷新、插入、回弹造成的误触发,参考 useScrollMarkReadGracePeriod.ts

如果你想继续追踪后续演进,可对照 0.4.2 之后各版本的 changelog,以及移动端总入口 App.tsx 与构建配置 app.config.base.ts 一窥整体架构。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.76 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
858
1.35 K
docsdocs
暂无描述
Markdown
899
5.82 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
923
1.85 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.83 K
1.02 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
532
596
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.03 K
524
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.37 K
1.46 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
393