普通视图

发现新文章,点击刷新页面。
昨天以前首页

Codex 这波大更新后,Mac 的含金量再次提升

作者 张子豪
2026年5月22日 17:43

「如果这条推文获得了一个赞,Codex 重置额度限制。」

已经数不清这是今年以来,第几次的限额重置了。奥特曼前两天在 X 发文,让 Codex 负责人 Tibo 再一次重置了使用限额。

网友做了一张梗图,每当一个人想走向 Anthropic 或 Gemini 时,奥特曼站在后面默默按下 Codex 限额重置的按钮,这个人就会回头,然后被拉回到 OpenAI。

OpenAI 这半年也因为出圈的 Codex 收获了一大批的新用户。外媒报道 OpenAI 第一季度营收达到了 57 亿美元,比 Anthropic 高出 10 亿美元,Codex 是主要因素。

▲ OpenAI 营收相关数据,季度营收达到 57 亿美元,年化收入 250 亿,第一季度调整后的营业利润率为 -122%,本季度周活跃用户平均约为 9.05 亿,在 2 月份的周活跃用户数曾达到约 9.2 亿,第一季度的付费用户数量为 5500 万,高于去年年底的约 4700 万。

我们在之前介绍过 Codex 的入门指南,从 ChatGPT 官网下载安装到连接手机上的 ChatGPT App 实现远程控制,都有详细的步骤。

不少读者在评论区留言,Codex 确实好用;也反馈了不少问题,像是下载 Codex 后仍需绑定手机号才能使用。我们的测试也发现登出之后再登录,确实会被要求绑定手机号。

这个时候,建议先在浏览器中进行登录,即主动打开网址 https://auth.openai.com/log-in 提前登录好。再回到 Codex 中登录,弹出的登录链接,只会显示要求授权即可,不会再有绑定手机号的提示。

不同的账号可能会遇到不同情况,大概也是眼下 OpenAI 在 Codex 这边投放了太多的算力,不希望被用户太轻易地薅走羊毛。

今天凌晨,Codex 又上新了一大波的新功能,现在只要按下电脑上的 Command-Command 键,就可将应用程序窗口附加到 Codex 的对话线程里。Codex 会自动获取窗口的屏幕截图和文本,包括屏幕上不可见的内容,作为对话的上下文。

以前还要自己手动截图,现在 Codex 不仅能处理截图,还能直接读到一整个应用窗口的信息。

此外,上次更新的在 ChatGPT App 内操作电脑上的 Codex 这一次也升级了,之前的选项是保持 Codex 常开,现在是即便电脑锁屏了, ChatGPT 同样能远程操作 Codex。

/goal 命令这次也从实验室版本来到了正式推出。之前我们分享多 Agents 协作时,就有读者提到 /goal 功能和多 Agents 类似,它们都是把一个任务当做一个项目来进行管理,有完整的目标生命周期,通过不同的机制来完成迭代。

/goal 最早是 4 月底出现在 Codex CLI 中,有了它确实也能更好的处理越来越多的长任务。

不过遗憾的是,无论是按 command 还是锁屏后继续远程控制,这些都是 macOS 平台的更新,对于 Windows 用户,只能等 OpenAI 的推进。

有网友说,「Mac 用户总是能享受到好东西,而 Windows 用户只能眼巴巴地看着,哈哈。」不得不说,Mac mini 作为 AI PC 的含金量还在增加。

省去很多麻烦的应用快照

这项功能叫 Appshots,开启它的方式也很简单,更新 Codex,在应用设置下,找到「应用快照」,就有一段视频教程,并且可以自定义快捷键。

不过需要注意的是,按下 command 键是指按下键盘上,空格键左右两边的两个 command 键,而不是单击两次。

在任何界面同时按下两个 command 键之后,Codex 会自动捕获页面截图,并快速打开 Codex 将截图放在输入框。我们可以针对这个窗口快照提出问题。

但基于 Codex 的能力,这个窗口快照不单是一张图片的 OCR 文本提取。Codex 可以再这个窗口的基础上,进一步使用 Computer Use 和 Chrome 自动化等功能。

▲ 图中只是在 Codex 的文章开头按下了 command,但是 Codex 不单是处理这张截图,而是会根据 Chrome 的能力,读取整个窗口。

例如,我们在飞书文档的文章开头同时按下了 command 键,然后告诉 Codex 要求它看看这个窗口讲了什么。Codex 会使用 Google Chrome 的工具,自动对网页进行浏览以获取更多的上下文。

这是它和一般截图最大的差别,除了把截图内容放进了上下文,Codex 还会自动把窗口的信息,来自哪个应用等状态信息,同步发送给 Codex。

▲ Codex 识别到了开头之后的文章内容

例如我们在微信里阅读公众号时,也能按下两个 command 键,开启 Appshots。但这里有一个小 Bug,当 Codex 使用 Computer Use 来控制微信的窗口,上下滑动公众号,退出图片的预览时,直接把微信给登出了。

▲暂不知道是微信识别到机器人操作的原因,还是 Codex 误操作,在退出图片预览时,直接退出了微信。建议用小号尝试 Computer Use 在微信中的应用。

官方在宣传视频里介绍 Appshots 时,同样不是简单地将它作为一张截图来使用,而是结合了 Computer Use 和 Google Chrome 来使用。

像是直接要求它修改我们的备忘录内容。

▲花了两分钟,帮我把备忘录的内容修改成了中英双语显示,直接在原备忘录上进行修改

还有也不用再复制什么图片,直接 command+command 然后告诉他生图提示词,对图片进行编辑。

▲ 在浏览器中打开了一张图片,告诉他生成涂鸦版本

就是这种应用多做了一步的感觉,我们就减少了很多 AI 的使用负担,让 Codex 的体验也变得更加丝滑。

/goal 的保姆级使用指南

在对话框内输入斜线,我们就能看到有「目标」的快捷选项,「设置 Codex 将持续努力实现的目标。」

目标存在的价值是作为一个独立存在的任务定义,而不是普通的对话提示词。Codex 会反复根据目标来判断「还该做什么」和「是否已经完成」,自动一轮接一轮的推进,直到任务完成、暂停或者烧到 Token 上限。

这两个判断也是目标的核心机制,即「延续」和「完成审计」。「延续」是在每轮结束后,自动注入提示,让模型决定下一步。「完成审计」是要求模型对照目标逐条核对。

Goal 模型最容易踩坑的地方,就是随手写一句话放进去。要写好一个 Goal,关键原则是 Codex 要能判断是否完成了。

官方在帮助文档也提到,好的目标应包含具体的结果、可衡量的指标或测试标准。他们给了一些案例,像是将项目从一种编程语言迁移到另一种编程语言。

把这个项目从 JavaScript 迁移到 TypeScript。

 

要求:以 strict 模式编译通过,不允许出现显式的 any 类型。

还有更直接的要求,「把首页的可交互时间压到 1 秒以内。」

这些例子都是有着具体的可验证标准,并不是「优化一下」、「完善一下」这种虚词。

 

▲ 图片来源 Goal 官方使用教程:https://developers.openai.com/cookbook/examples/codex/using_goals_in_codex

如果没有想到具体标准,Codex 建议是先跑 /plan。让 Codex 和我们讨论一轮,把验收标准定清楚,再切回普通模式下 /goal。

还有一些实用小建议是,可以在 goal 文本末尾加一句 Use a token budget of 80000 tokens for this goal,用来设置 Token 预算。

以及不要在一个会话的开头就发送 /goal,而应该是先给这个项目其他的需求,有一定的雏形,再给它目标。

锁屏了,Codex 还能操作你的电脑

除了这些大的更新,Codex Thursday 还带来了很多体验升级的功能。

Locked Computer Use 是最值得一提的一项,简单来说它就是能让 Codex 在 Mac 锁屏之后,仍然能在后台操控桌面应用完成任务。

网友对这项功能的评价,都集中在这是突破性的,这很有未来感的同时又很吓人。

如果 Codex 能够在没有活跃用户会话的情况下运行 Mac 应用,这或许是迈向持久 Agent 基础架构的第一步。

若要使用锁屏后继续操作的功能,必须由我们手动开启,并且输入密码。打开的方式同样是在设置里,找到电脑操控,开启锁屏操作。

正常的 Computer Use 需要屏幕处于解锁状态,Codex 才能「看到」并操作界面。这个功能打破了该限制,我们可以把 Mac 合上或锁屏,然后从手机、iPad 或另一台设备远程发起 Codex 任务,它会自动临时解锁、完成操作、然后重新锁上。

Codex 为此安装了一个 Apple Authorization Plug-in(苹果官方授权的认证插件),接入 macOS 的解锁流程。当有活跃的 Computer Use 任务时,插件允许 Codex 临时解锁屏幕;任务窗口之外,解锁权限直接拒绝。

OpenAI 也对这个功能做了几层约束,防止它变成其他危险操作的后门:

  • 解锁窗口极短,仅限当前 Computer Use 操作期间有效
  • 覆盖所有显示器,临时解锁期间屏幕内容对物理旁观者不可见
  • 检测到本地输入立即重锁——有人碰了键盘或鼠标,自动暂停,要求手动解锁
  • 这个路径只对 Codex 开放,其他应用或本地进程无法借道

另一项高级标注的功能,则是我们在使用 Codex Vibe Coding 某个网页时,通过 Codex 内置的浏览器打开,同时还提供了直接在网页内容上进行修改的标注工具。

除了 Codex 这一系列的更新,今天 ChatGPT 也上新了一项新功能,ChatGPT 现在可以直接在 PowerPoint 中创建和编辑演示文稿,并且还能使用 GPT Image 2 生成用于 PPT 里面的图片。

Codex 越来越好用的同时,钱包燃烧的速度也在加快。

我们的 Pro 账号,每周使用限额要到 27 号重置,但是今天(22 号)就只剩下 10% 了。只能在心里默默「作法」,祈祷它再一次重置。

如果这篇文章获得了一个赞,你的 Codex 有可能重置额度限制🐶

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

连时间都没法看的手环,每年敢收 1000 块,居然比苹果还火?

作者 苏伟鸿
2026年5月14日 12:27

Google 可以说是 5 月最出尽风头的科技公司,趁着 Goog I/O 大会的节点,一口气发布了大量新产品——

有颠覆手机系统的智能系统和 Gemini Intelligence,有颠覆电脑使用范式的 Googlebook,还有这个要颠覆智能手表的无屏手环 Fitbit Air——内嵌 Gemini,主打 AI 健康功能。

为此,Google 不仅重新捡起了快被扫进垃圾堆的 Fitbit,还请来 NBA 当家球星库里做代言。野心很明显,就是要在智能穿戴拿下一块市场。

不过,Fitbit Air 瞄准的并不是苹果 Apple Watch,而是对标这两年大火的「WHOOP」——这是一家创立 12 年,却在近两年异军突起的科技新贵,无屏手环是他们最主要的产品。

WHOOP 从产品定义到营销策略,都相当特立独行。他们不卖手环,直接送!只是如果想正常使用,要收一笔不菲的订阅费,来开启 AI 健康功能。

今年 3 月,WHOOP 完成 6 亿美元融资,估值超过 100 亿美元。

在这个充电头都会加一块屏幕的时代,一个没有屏幕的智能手环却卖爆了,凭什么?

无感设计,极致简洁

WHOOP 并不是一家初创公司,实际上成立于 2012 年,早于初代 Apple Watch,和 FitBit、Pebble 等名字并列为智能手表的探索者。

在那个技术还没那么发达的年代,手腕上没法集成太多东西,所以像主打健身健康的 Fitbit 和 WHOOP,初代产品基本上都是一个没屏幕的手环,反馈和交互靠手机 App 完成,本质上和在手腕上绑了一堆传感器无异。

图源:Tom’s Guide

随着技术发展,以及 Apple Watch 对用户心智的教育,类似的智能手环产品很快也都配备了一个小屏幕——在当时,手腕上的触控屏是大势所趋,要不然为什么要无缘无故给身上带个不便宜的手环?

WHOOP 不这么认为,时至今日他们的手环产品依旧没有屏幕,仿佛这十几年的智能穿戴变革浪潮,将他们给遗忘了。

一开始做无屏是妥协,现在还在做无屏,那就是「坚持」了。

实际上,「无屏」这件事,让 WHOOP 变成了一个异常简单的产品,没有太多复杂性和限制,带来了使用上的自由。

创始人 Will Ahmed 认为,一旦为 WHOOP 配备屏幕,那么就会不断加入时间、健康提醒、消息提醒这些功能,成了一块智能手表。这个赛道,很难和苹果三星等品牌竞争。

Will Ahmed

Reddit 上不少用户也表示,选择 WHOOP 的原因,就是因为它没有一块让人分心的屏幕,够纯粹。

WHOOP 的本质就是一个健康传感器,你可以将它佩戴在在身上的不同地方,官方也推出了可以和 WHOOP 配套使用的贴身衣物配件,这种灵活性是智能手表和智能指环都难以媲美的。

Apple Watch 的逻辑,是把更多信息带到手腕上;WHOOP 的逻辑,则是设备彻底退居幕后。

追根溯源,最终让 Apple Watch 得以成立并走进千家万户的原因,也是因为苹果为其找到了「运动健康」这个主要定位。

图源:CNET

这就带来了一个很有意思的反转:Apple Watch 本来应该杀死智能手环的比赛,但随着智能手表越来越普及,也越来越多人能理解「在身上绑传感器」的价值,反而让 WHOOP 极致简洁的价值得以凸显。

时至今日,依然有不少人更偏爱具备美学价值的传统机械表,又向往 Apple Watch 的健康监测功能,只是手上同时戴两块表难免显得有些别扭。爱范儿也采访了身边的真实用户 @flypig,他告诉我们:

因为我有不少机械腕表,我认为是一些蛮好看的饰品,根据心情换着戴。但我又有记录身体状态的需求。我不希望左手一块机械腕表,右手一块 Apple Watch。我试过,感觉戴两块表看起来还是太怪了。

单论产品形态,WHOOP 比 Apple Watch 有不少优势:没了屏幕,换来了两三周的续航,还有极致的轻便,顶配版也不到 30 克,佩戴在身上几乎无感;硬件本身成本极低,会员提供终身保修,非常适合极限运动爱好者,丢了换新也只需要 50-80 美元不等的补办费。

由于 WHOOP 手环本身没有任何交互,只需要「佩戴」,这意味着对于儿童,特别是更需要健康监测的老人群体来说,没有任何使用的门槛。

让人愿意戴,并且愿意长时间戴,这就是智能穿戴产品最重要的优势,这样看来,极致简洁的 WHOOP 已经是一个相当有价值的产品。

普惠,从运动员到每个人

10 年的时间,智能穿戴领域瞬息万变,最早的行业竞争者 Fitbit 和 Pebble 在苹果等大厂入局后,都走向了沉寂。

WHOOP 不仅顽强存活了 12 年,还在这两年大火。这个看似「逆袭」的故事,或许从一开始,就已经埋下了伏笔。

在成军之初,WHOOP 就没有瞄准大众用户,功能不止于步数、心率这些常规体征数值,直接聚焦「睡眠」「恢复」和「负荷」三大指标,用量化的分数告诉用户每一天醒来恢复程度如何,今天是否适合训练。

这种功能源自于创始人 Will Ahmed 自己大学时期作为壁球队员的经历:他发现自己很难达到训练量和恢复之间的平衡,于是开发了专注于追踪恢复、运动负荷和睡眠指标的 WHOOP。

由运动员打造的产品,自然最懂运动员,因此 WHOOP 在最初就瞄准了运动员这个非常独特的用户群体。

虽然这让他们在大众消费者之间的知名度更低,却避开了和 Fitbit 和 Apple Watch 竞争,拿下了包括美国职业棒球联盟在内很多职业运动队的独家订单。

并且,WHOOP 的专业属性很快也获得了 NBA 明星勒布朗 · 詹姆斯,和「C 罗」克里斯蒂亚诺·罗纳尔多这样的重量级用户作为「自来水」,不愁曝光和知名度。

这也是 WHOOP 的聪明之处。 不管是什么消费品,由大众产品冲击高端很难,但反过来要轻松得多了。

对于中产阶层来说,WHOOP 自带了一个「精英运动员同款」的光环,建立起「专业」的品牌形象,只差购买的契机。

2018 年开始,WHOOP 将产品从原本的 500 美元售价,转变成 6 个月起订,每个月 30 美元的订阅方式,大幅降低了准入门槛,正式向大众消费市场进军。

这个转变也彻底改写了 WHOOP 的商业模式:从一家「硬件」品牌,转变为了售卖软件的服务商。

手环是 WHOOP 商业模式的起点,却并非核心。这个手环几乎是「白送」给用户,WHOOP 在官网售卖的直接就是「订阅」,买的是一年的会员,成本直接包含在订阅费之中,免费试用一个月甚至直接送你一个手环。

当然,WHOOP 不同档位的会员,能拿到的表带配置有所不同,只是 WHOOP 不需要你为硬件付费,套餐可以随时换。

购买 Apple Watch 之后,只要硬件本身没有故障,理论上你获得了这个产品的终身使用权;至于 WHOOP,只要停止续费,那么你手上的表带就没有任何价值。

硬件形态只能吸引体验,软件体验才能真正留住人心,对于 WHOOP 来说更是如此。

WHOOP 的手机应用,把身体数据做成了一种几乎不需要学习成本的「身体仪表盘」,各种可视化图表,自己今天睡得怎么样、锻炼强度如何,身体年龄多少,一目了然。

普通人或许很难理解自己的「恢复」分数究竟算高还是低,但通过颜色区分,身体状态会变得非常直观—— 绿色意味着恢复状态良好,今天可以正常训练;红色则像是一张警告牌,提醒你身体还没缓过来,最好暂停运动。

全球疫情之后,越来越多人开始关注自己的「身体状态」,而不只是单纯记录运动数据。这个趋势,恰好撞上了 WHOOP 从「运动装备」向「生活方式品牌」的转型期。

与此同时,随着房颤、血压趋势等健康监测功能不断加入,再加上 WHOOP 4.0 在续航、体积和佩戴体验上的全面升级,以及免费试用机制降低门槛,越来越多原本并不热衷运动的普通用户,也开始接触并接受这个品牌。

从运动员的明星光环带动,到低门槛高价值的使用方式,WHOOP 也从从一款运动员工具,变成了一种新的中产社交符号,就像 Lululemon、冷水浴、燕麦奶。

你未必真的运动,但只要把 WHOOP 戴在手上,就仿佛已经进入了那个高度自律、关注健康、持续优化自己的生活方式体系——至少外人看来如此。

买 AI 硬件,就是为 AI 付费

WHOOP 这种订阅制的商业模式,对于智能手表来说很超前,但和当下热门的「AI 硬件」,又惊人地相似。

用 Plaud 举例,只要你是真正的录音笔目标用户,基本绕不开会员订阅,才能获取足够的录音转写时长,而它体验的核心,就是那个负责整理、理解和调用录音内容的 App。

以前做产品和硬件,我们会推崇「All in One」,在有限的机身中尽可能配备更多的功能。

而现在,我们看到许多真正跑通的「AI 硬件」,反而都回到了更简单的形态:功能单一、交互克制,却能够深入某个具体场景,持续收集数据,再交由后端 AI 去整理、理解,并最终生成真正有价值的结果。

WHOOP 也是如此,它的形态,真的就是将传感器绑在身上,贴近身体的同时又保证尽可能无感。

智能穿戴近年的方向,也已经不止于单纯收集数据,还要呈现简单易懂的结果。Apple Watch 的「生命体征」功能,本质上就是把体温、血氧、心率、睡眠等趋势,浓缩成「身体是否异常」的直观指标。

2022 年底,ChatGPT 掀起了生成式 AI 的浪潮,每个行业都在借助 AI 改造自己的产品和商业模式。2023 年 9 月,WHOOP 接入了 GPT-4,推出「WHOOP 教练」功能,利用大模型对用户的数据进行解读,提供更个性化的建议,属于第一批尝试将 AI 和健康相结合的厂商。

由于布局时间早,WHOOP 算得上是当今智能穿戴产品中 AI 健康功能的第一梯队。CNET 的编辑尝试过很多类似的产品,唯独觉得 WHOOP 是「好用」的:

她忘记了自己即将要来月经,而 WHOOP 教练提前两天通知她激素发生变化,因此锻炼会更吃力,建议降低强度,并在月经期间根据她的恢复情况,智能轮换调整了她的训练计划,很好帮她纠正了「练得多=更好」的心态。

@flypig 则表示:

我认为它的 AI 能力——特别是那个 LLM 问答框的体验——实属一般般。但它 AI 之外的智能建议和规划能力,我认为,很不错,够用了。我愿意每年给他们交钱。

从 WHOOP 可以看出,健康监测和 AI 属于一种「双向奔赴」。

智能穿戴设备能持续对身体的指标进行长时间监控,但累积了海量数据之后,对用户的价值却没那么大。

而想要从 AI 那里获得很好的结果,就必须要提供充足且高质量的上下文,智能穿戴设备获得的数据,刚好可以让 AI 进行和解读。

智能穿戴接下来要实现的跃迁, 不仅要替你解读数据,还要根据数据直接给你实在的建议。

这也是为什么,沉寂了好几年的 Fitbit 忽然被复活。

本来 Google 已经有了 Pixel Watch 的可穿戴产品线,不带屏幕的 Fitbit 的重点放在了 AI 健康模式上,为自家的 Gemini 找到了另一个落地的场景,不只是 Google 生态,甚至在打苹果阵营的主意。

Google Healthm

我依旧不会将 WHOOP 或者 Fitbit 称作一个「AI 硬件」, 它们更像是借助 AI 完成了一次新的叙事包装,核心依旧是健康监测那套已经被验证了十多年的逻辑,这也是它们能重新进入大众视野的原因。

当传感器本身逐渐触及技术瓶颈,智能穿戴真正重要的问题,也从「还能测到什么」,逐渐过渡到「如何理解这些数据」,WHOOP 在讲的,就是这样一个故事。

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

下个月的苹果 WWDC,假如 iCloud 变成 iClaw……?

作者 苏伟鸿
2026年5月11日 18:14

距离苹果全球开发者大会 WWDC 还有一个月不到的时间,彭博社又送上关于苹果新系统的全新爆料:

苹果准备对 macOS 27 的界面进行一轮小幅调整,进一步完善「液态玻璃」设计语言的视觉表现。

但问题在于,对于如今的 Mac 来说,除了需要继续打磨的 UI,最迫切的更新显然远不止于此。

液态玻璃,缝缝补补又一年

对比 iPhone 以及 iPad,Mac 的性能和续航都更有盈余,实际上 macOS 26 的界面,视觉效果要更接近去年 WWDC 上面演示的「满血版」液态玻璃。

不过,液态玻璃立项之初,就是专门为 OLED 屏幕设计的,而目前所有 Mac 产品都在使用 LCD 屏幕,在呈现半透明、阴影和玻璃质感的方面,效果不如 OLED。

于是 macOS 26 的一些高透明效果和阴影,会导致列表和文字可读性下降——这也是「液态玻璃」被一直诟病的问题。

和 iOS 27 一样,macOS 27 也将仔细打磨液态玻璃,让它更接近苹果一开始设想的效果:兼顾透明度和可读性,同时进一步优化能耗表现。

不过,macOS 26 在 UI 上的问题根本不止于液态玻璃本身,不对齐的圆角、大量分散注意力的小图标,以及重新设计后辨识度大打折扣的应用图标,对可读性和美观层面都造成了一定的影响。

图源:Daring Fireball

苹果评论员 John Gruber 对于 macOS 液态玻璃的点评相当一针见血:作为一种「内容优先」的设计语言,液态玻璃让系统 UI 隐身于媒体之后,在 iPhone 上或许能行,但作为强调生产力而非内容消费的桌面平台,Mac 包含大量的窗口、组间,因此复杂性更高,仍然需要应用界面保证清晰的结构、分明的功能区域,和强辨识度的界面。

在 Stephen Lemay 接任设计总监一职后,这位在苹果服务近 30 年的老将表现让人期待——Lemay 以公司内部的高口碑和稳定发挥著称,或许也称得上是苹果内部目前最懂苹果系统界面的人。

在他的把控下,macOS 27,以及 iOS 27 如何扭转液态玻璃褒贬不一的口碑,回到实用性和美感并举的方向,确实值得期待。

但对于 macOS 来说,界面上的「拨乱反正」固然必要,却已经不是最重要的更新了。

对苹果而言,未来系统的更新有两条主线:一方面,优化系统稳定性,另一方面,则是为 Apple 智能预备好。

最好的 AI 载体,需要一个 AIOS

根据彭博社爆料,苹果打算为「Apple 智能」打造一个「Extensions」功能,允许用户更换第三方 AI 模型,例如 Google Gemini、Claude 等等。

Siri 除了会集成到邮件、短信、相册等应用,自己也会化身聊天机器人,成为一个单独的应用。更多 AI 功能还会覆盖文本、图像等生成与编辑任务。

但这些更新,说实话更多还是做 AI 的单点功能,并非系统级别的编排能力,并未能进一步发挥 Mac 硬件上的优势。

今年年初的龙虾热,让 Mac mini 这个前年才火过的产品,又再一次出圈,这次火到苹果自己也没库存了,「入门版」在官网彻底售罄。

Mac 和 Windows 在不少层面上互有胜负,但在 AI 的问题上,Mac 作为「最佳 AI 容器」的论断几乎毫无争议。

关于这个问题,爱范儿已经出过一篇文章详细讨论。简单来说,就是因为 Mac 不管是 UNIX 系统底层还是集成运存的硬件架构,都非常契合 AI Agent 和大模型的运行方式,并且由于 ARM 架构的特性,运行功耗低还静音,非常适合 AI 常驻。

这更像是「无心插柳柳成荫」,苹果其实一开始并没有围绕 AI 去打造自己的 Mac,却无意间完成了所有 AI 的技术储备,严格意义上说是一种「适配度优势」。

从这个角度看,macOS 即使什么都不做,本身 Mac 也已经是一个很好的 AI 平台。苹果完全可以走 App Store 的逻辑,让用户自己部署想要的第三方 AI 智能体,自己继续扮演「收过路费」的角色。

这确实也是苹果长期以来的做法:在移动互联网兴起之时,苹果没必要自己做搜索引擎和网购平台。而 AI 时代,大众的需求变化万千,有人需要一个能剪辑的 Agent,也有人需要一个搞科研的 AI,必须要靠第三方满足。

在今年 5 月的财报会议上,苹果特别提到了 AI 公司 Perplexity 的智能体产品 Personal Computer,认为这种产品很好利用了 Mac 平台的能力。

既然觉得人家做得不错,何不自己上手做一个「iClaw」?

第三方 AI 百花齐放固然很好,这和苹果自己做一个却并不冲突,并且很多事情,只有第一方能做得好,能做得让人放心。

第三方应用再强,也很难自然获得系统级的上下文,苹果不可能将最底层的权限开放,只有系统底层自己能对文件位置、窗口状态、本地个人数据知道一清二楚,而 AI 应用的体验,往往就卡在了这些权限边界之上。

其实苹果并不是没有这种想法,那个迟迟没能推出的 AI Siri,其实就有着类似的构想,可以读取用户的文本和应用窗口,可以跨应用进行检索和处理。

对比 iPhone 和智能手机,AI 应用的主流使用场景其实还是在于桌面端,这也是为什么 Mac 能成为今年最热门的 AI 硬件,但苹果却没有继续在 macOS 的系统层面,赋予 Mac 足够分量的原生 AI 能力。

隔壁的 Windows 阵营在这方面要激进不少,系统层面有「Recall」和 「Copilot」这样的 AI 功能入口,联想和荣耀这样的 OEM 厂商,甚至为产品准备了开箱即用的龙虾应用,砍掉了门槛,并因为和本地深度集成,能节省不少 Token。

微软自己也已经坐不住了,据悉正在将原本只能你问我答的 Copilot,改造成一个 24/7 在线的数字分身,实现类龙虾能力。

对比 OpenAI、Anthropic 或者 Google,说实话我更愿意将这些敏感的数据,交给在隐私保护方面更上心的苹果。

更深一步,macOS 最缺少的不是 AI 应用,而是 AI 时代的「基建」。Mac 已经准备好了 AI 大有可为的土壤,但 macOS 还没能成为一个真正意义上的「AI 系统」。

苹果不仅可以做自己的 AI 智能体能力,也需要把模型、权限、上下文、自动化和跨应用任务重新梳理,让系统成为 AI 工作流的原生中介,成为一个掌控所有 AI 的「任务集散中心」。

就像是智能体运行所需要的「个人知识库」,现在我们用文件夹也可以搭建,但它还不够好用。

苹果完全可以自己承接这个环节,用户靠 Mac 自带的工具搭建、生成一个「知识库」文件,它可以和 Apple ID 绑定,利用 iCloud 流转,这样不管用哪一家的智能体服务,都能快速调用自己的知识库,不用从头开始配置,同时还能保证自己的内容被苹果的隐私政策保护。

并且,这些配置的模块都能整合进入 Apple 的订阅系统之中,iClaw 和 Token 也能成为苹果在 AI 时代提供的增值服务。

iClaw 示意图,AI 生成

实际上,苹果已经开始了这样的进程。在 macOS 26.1 中,苹果集成了「模型上下文协议」,一个面向不同 AI 的通用开放标准,Agent 可以通过这个协议,访问用户的个人数据;苹果的基础模型框架,让 macOS 开发者可以调用系统内置的基础模型,零网络延迟,零 API 费用,数据不离开设备。

作为计算机图形系统的祖师爷,macOS 在过去的数十年间都是围绕「应用」构建的桌面系统。

在接下来十年,应用和图形界面还会是人机交互的主流,因此 macOS 27 要将界面风格修缮得更好,当然非常重要。

但未来五十年甚至更远,AI 都会成为无可避免的主旋律,macOS 不可避免会被进一步改造,成为一个围绕「任务」运转的 AIOS。

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

全面重构的 uni-app 多平台上传组件,功能强到离谱!

2026年5月8日 15:05

一、前言

在移动应用开发中,文件上传是一个高频且复杂的需求场景,无论是用户头像上传、图片分享,还是文档提交、视频发布,都离不开一个稳定、易用的上传组件。

uView Pro 的 u-upload 组件经过几次迭代、重构,现已支持图片、视频、文档等多种文件类型,提供网格(grid)和列表(list)两种展示模式,完全向后兼容的同时带来了更强大的功能和更优雅的使用体验。

二、组件核心优势

1. 多文件类型支持

不再局限于图片上传,u-upload 现已支持:

  • 图片 - 支持预览、压缩、多选
  • 视频 - 支持时长限制、摄像头方向设置
  • 文件 - PDF、Word、Excel 等文档类型(H5/微信小程序)
  • 媒体文件 - 图片+视频混合选择
  • 所有类型 - 一键开启全类型支持

0.png

2. 双模式展示

根据文件类型自动适配最佳展示方式:

网格模式(默认) - 适合图片展示

  • 宫格布局,视觉整齐
  • 支持图片预览、删除
  • 适合头像、相册等场景

1.png

列表模式 - 适合文件展示

  • 显示文件名、文件大小
  • 进度条直观展示上传状态
  • 适合文档、资料上传场景

2.png

3. v-model 双向绑定

最新版本告别繁琐的事件监听,支持双向绑定,一行代码实现数据同步:

<u-upload :action="action" v-model="fileList"></u-upload>

4. 全平台兼容

完美适配 uni-app 所有平台:

  • App(Android/iOS/鸿蒙)
  • H5
  • 微信小程序、支付宝小程序、百度小程序、头条小程序、QQ小程序

三、快速上手

1. 基础用法

最简单的上传配置,只需设置服务器地址:

<template>
    <u-upload :action="action" v-model="fileList"></u-upload>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const action = ref('https://your-server.com/upload')
const fileList = ref([
    {
        url: 'https://example.com/avatar.jpg',
        name: 'avatar.jpg',
        size: 1024 * 50,
        progress: 100,
        error: false
    }
])
</script>

2. 上传不同文件类型

通过 accept 参数一键切换文件类型:

<!-- 上传图片(默认) -->
<u-upload :action="action" accept="image"></u-upload>

<!-- 上传视频 -->
<u-upload :action="action" accept="video" :max-duration="120"></u-upload>

<!-- 上传文件(H5/微信小程序) -->
<u-upload :action="action" accept="file" :extension="['.pdf', '.docx']"></u-upload>

<!-- 上传所有类型 -->
<u-upload :action="action" accept="all"></u-upload>

3. 展示模式切换

<!-- 网格模式 - 适合图片 -->
<u-upload :action="action" accept="image" mode="grid"></u-upload>

<!-- 列表模式 - 适合文件 -->
<u-upload :action="action" accept="file" mode="list" :show-file-name="true" :show-file-size="true"></u-upload>

4.png

四、进阶功能

1. 手动上传控制

默认自动上传,也可改为手动控制:

<template>
    <view>
        <u-upload ref="uUploadRef" :action="action" :auto-upload="false"></u-upload>
        <u-button @click="submit" type="primary">提交上传</u-button>
    </view>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const action = ref('https://your-server.com/upload')
const uUploadRef = ref()

function submit() {
    // 手动触发上传
    uUploadRef.value?.upload()
}
</script>

3.gif

2. 上传前处理

通过 before-upload 钩子实现自定义逻辑:

<template>
    <u-upload :before-upload="beforeUpload" :action="action"></u-upload>
</template>

<script setup lang="ts">
async function beforeUpload(index: number, list: any[]) {
    // 示例:上传前获取签名
    const sign = await getUploadSign()
    
    // 返回 true 继续上传,false 跳过当前文件
    return !!sign
}

async function getUploadSign() {
    // 模拟获取上传签名
    return 'upload-sign-xxx'
}
</script>

3. 文件限制

灵活控制上传文件的数量、大小和类型:

<u-upload 
    :action="action"
    :max-count="6"                    <!-- 最多选择6个文件 -->
    :max-size="5 * 1024 * 1024"       <!-- 单个文件最大5MB -->
    accept="image"
    :limit-type="['png', 'jpg', 'jpeg']"  <!-- 限制图片格式 -->
></u-upload>

4. 自定义文件选择

对于不支持文件选择的平台(如 App),可以通过 custom-choose 属性开启自定义选择:

<template>
    <u-upload 
        ref="uploadRef"
        accept="file"
        :custom-choose="true"
        :action="action"
        @on-choose="handleCustomChoose"
    ></u-upload>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const action = ref('https://your-server.com/upload')
const uploadRef = ref()

// 自定义文件选择
function handleCustomChoose({ accept, maxCount, fileList, index }: any) {
    // App 端使用原生文件选择
    // #ifdef APP-PLUS
    plus.runtime.chooseFile({
        success: (res: any) => {
            const files = res.files.map((file: any) => ({
                path: file.path,
                name: file.name,
                size: file.size,
                fileType: 'file'
            }))
            // 将文件添加到组件
            uploadRef.value?.addFiles(files)
        }
    })
    // #endif
}
</script>

核心要点:

  1. 设置 :custom-choose="true" 开启自定义选择模式
  2. 监听 @on-choose 事件,自行处理文件选择逻辑
  3. 选择完成后调用 uploadRef.value?.addFiles(files) 将文件添加到组件

5. 自定义上传按钮

通过插槽打造个性化上传入口:

5.png

<u-upload :custom-btn="true">
    <template #addBtn>
        <view class="custom-upload-btn">
            <u-icon name="plus" size="40" color="#2979ff"></u-icon>
            <text class="upload-text">点击上传</text>
        </view>
    </template>
</u-upload>

<style>
.custom-upload-btn {
    width: 200rpx;
    height: 200rpx;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    background: #f5f5f5;
    border-radius: 10rpx;
    border: 2rpx dashed #ddd;
}
.upload-text {
    margin-top: 10rpx;
    font-size: 24rpx;
    color: #666;
}
</style>

6. 完全自定义文件列表展示

通过 file 插槽完全自定义文件列表的展示方式,实现更灵活的文件管理界面:

6.png

<template>
  <u-upload
    ref="customFileListRef"
    v-model="customFileList"
    accept="file"
    mode="list"
    :action="action"
    :show-upload-list="false"
    :custom-btn="true"
    :max-count="5"
  >
    <!-- 自定义文件列表 -->
    <template #file="{ file }">
      <view class="custom-file-list">
        <view 
          v-for="(item, index) in file" 
          :key="index" 
          class="custom-file-item"
        >
          <!-- 文件类型图标 -->
          <u-icon
            :name="isImageFile(item) ? 'photo' : 'file-text'"
            size="40"
            color="var(--u-type-primary)"
          />
          
          <!-- 文件信息 -->
          <view class="custom-file-info">
            <text class="custom-file-name">{{ item.name || '未命名文件' }}</text>
            <text v-if="item.size" class="custom-file-size">
              {{ formatSize(item.size) }}
            </text>
          </view>
          
          <!-- 上传进度条 -->
          <view
            v-if="item.progress < 100 && item.progress > 0"
            class="custom-file-progress"
          >
            <u-line-progress :percent="item.progress" height="8" />
          </view>
          
          <!-- 上传状态 -->
          <view class="custom-file-status">
            <u-icon
              v-if="item.progress === 100"
              :name="item.error ? 'close-circle' : 'checkmark-circle'"
              size="34"
              :color="item.error ? 'var(--u-type-error)' : 'var(--u-type-success)'"
            />
            <text v-else class="custom-file-progress-text">
              {{ Math.floor(item.progress || 0) }}%
            </text>
          </view>
          
          <!-- 删除按钮 -->
          <view class="custom-file-delete" @click="removeCustomFile(index)">
            <u-icon name="close" size="24" color="var(--u-tips-color)" />
          </view>
        </view>
      </view>
    </template>
    
    <!-- 自定义添加按钮 -->
    <template #addBtn>
      <view class="custom-file-add-btn">
        <u-icon name="plus" size="32" color="var(--u-type-primary)" />
        <text class="custom-file-add-text">添加文件</text>
      </view>
    </template>
  </u-upload>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import type { UploadFileItem } from '@/uni_modules/uview-pro/types/global'

const action = ref('https://your-server.com/upload')
const customFileList = ref<UploadFileItem[]>([])
const customFileListRef = ref()

// 判断是否为图片文件
function isImageFile(item: UploadFileItem): boolean {
  const ext = item.name?.split('.').pop()?.toLowerCase() || ''
  return ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'].includes(ext)
}

// 格式化文件大小
function formatSize(bytes: number): string {
  if (bytes === 0) return '0 B'
  const k = 1024
  const sizes = ['B', 'KB', 'MB', 'GB']
  const i = Math.floor(Math.log(bytes) / Math.log(k))
  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}

// 删除文件
function removeCustomFile(index: number) {
  customFileListRef.value?.remove(index)
}
</script>

<style scoped>
.custom-file-list {
  width: 100%;
  margin-bottom: 20rpx;
}

.custom-file-item {
  display: flex;
  align-items: center;
  padding: 24rpx;
  background: var(--u-bg-white);
  border-radius: 12rpx;
  margin-bottom: 16rpx;
  border: 1rpx solid var(--u-border-color);
}

.custom-file-item:last-child {
  margin-bottom: 0;
}

.custom-file-info {
  flex: 1;
  margin-left: 20rpx;
  display: flex;
  flex-direction: column;
  min-width: 0;
}

.custom-file-name {
  font-size: 28rpx;
  color: var(--u-main-color);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.custom-file-size {
  font-size: 24rpx;
  color: var(--u-tips-color);
  margin-top: 8rpx;
}

.custom-file-progress {
  width: 120rpx;
  margin-left: 20rpx;
}

.custom-file-progress-text {
  font-size: 24rpx;
  color: var(--u-primary-color);
}

.custom-file-status {
  margin-left: 20rpx;
  min-width: 48rpx;
  display: flex;
  justify-content: center;
  align-items: center;
}

.custom-file-delete {
  display: flex;
  align-items: center;
  margin-left: 20rpx;
  padding: 8rpx;
}

.custom-file-add-btn {
  display: flex;
  flex-direction: row;
  align-items: center;
  justify-content: center;
  background: var(--u-bg-white);
}

.custom-file-add-text {
  margin-left: 16rpx;
  font-size: 28rpx;
  color: var(--u-tips-color);
}
</style>

核心要点:

  1. 隐藏默认列表:设置 :show-upload-list="false"
  2. file 插槽:接收 { file } 参数,file 即当前文件列表
  3. 文件属性
    • item.name - 文件名
    • item.size - 文件大小(字节)
    • item.progress - 上传进度 0-100
    • item.error - 上传失败标记
  4. 操作文件:通过 ref 调用 remove(index) 删除文件
  5. 进度展示:使用 u-line-progress 组件显示上传进度

五、实际应用场景

场景一:用户头像上传

<u-upload 
    accept="image"
    image-shape="circle"
    :action="action" 
    :max-count="1"
    :max-size="2 * 1024 * 1024"
    :limit-type="['jpg', 'png']"
    @on-success="onAvatarSuccess"
></u-upload>

7.png

场景二:资料文档上传

<u-upload 
    accept="file"
    mode="list"
    :action="action"
    :show-file-name="true"
    :show-file-size="true"
    :extension="['.pdf', '.doc', '.docx']"
></u-upload>

场景三:视频作品发布

<u-upload 
    accept="video"
    camera="back"
    :action="action" 
    :max-count="1"
    :max-size="50 * 1024 * 1024"
    :max-duration="300"
></u-upload>

六、平台适配说明

虽然 u-upload 已实现全平台支持,但部分功能在不同平台存在差异:

功能 App H5 微信小程序 支付宝小程序
图片上传
视频上传
文件上传
文件预览
压缩选项

最佳实践建议:

  • 文件上传功能在 H5 和微信小程序体验最佳
  • 如需在 App 中使用文件上传,建议使用原生能力或第三方 SDK
  • 生产环境务必做好各平台的真机测试

七、总结

uView Pro 的 u-upload 组件经历了从单一图片上传到全能文件管理。无论是简单的头像上传,还是复杂的资料提交,还支持高度自定义,无论如何都能找到最适合的配置方案。

核心亮点:

  • 多类型支持 - 图片、视频、文档全覆盖
  • 双模式展示 - 网格/列表随心切换
  • 高度自定义 - 插槽机制、自定义满足个性需求
  • 全平台适配 - 一套代码多端运行

附录:API 完整参考

Props 参数

参数 说明 类型 默认值 可选值
action 服务器上传地址 String '' -
accept 接受的文件类型 String image image / video / file / media / all
image-shape 图片/图标展示形状 String square circle / square
modelValue 文件列表(推荐,v-model 双向绑定) Array [] -
file-list 默认显示的文件列表(旧版,建议使用 v-model) Array [] -
custom-choose 是否使用自定义文件选择 Boolean false true / false
mode 展示模式 String grid grid / list
max-count 最大选择文件的数量 String/Number 52 -
max-size 选择单个文件的最大大小,单位字节 String/Number Number.MAX_VALUE -
width 预览区域和添加按钮的宽度,单位rpx String/Number 200 -
height 预览区域和添加按钮的高度,单位rpx String/Number 200 -
multiple 是否开启文件多选 Boolean true true / false
disabled 是否禁用组件 Boolean false true / false
auto-upload 选择完文件是否自动上传 Boolean true true / false
deletable 是否显示删除文件的按钮 Boolean true true / false
show-confirm 删除文件前是否显示确认弹窗 Boolean true true / false
show-tips 特殊情况下是否自动提示toast Boolean true true / false
show-progress 是否显示上传进度条 Boolean true true / false
show-upload-list 是否显示组件内部的文件预览列表 Boolean true true / false
show-file-name 是否显示文件名 Boolean true true / false
show-file-size 是否显示文件大小 Boolean false true / false
preview-full-image 是否可以通过 uni.previewImage 预览已选择的图片 Boolean true true / false
preview-file 是否可预览文件(非图片类型) Boolean true true / false
custom-btn 是否自定义选择文件的按钮 Boolean false true / false
upload-text 选择文件按钮的提示文字 String 根据accept自动显示 -
image-mode 预览图片的显示模式 String aspectFill -
del-icon 右上角删除图标名称 String close -
del-bg-color 右上角删除按钮的背景颜色 String var(--u-type-error) -
del-color 右上角删除按钮图标的颜色 String var(--u-white-color) -
header 上传携带的请求头信息 Object {} -
form-data 上传额外携带的参数 Object {} -
name 上传文件的字段名 String file -
size-type original 原图,compressed 压缩图 Array ['original', 'compressed'] -
source-type 选择文件的来源,album-相册,camera-相机 Array ['album', 'camera'] -
limit-type 限制允许上传的文件后缀,优先级高于accept Array [] -
extension 选择文件时的扩展名过滤,仅H5和微信小程序有效 Array [] -
file-icon-map 文件类型图标映射配置 Object {} -
compressed 选择视频时是否压缩 Boolean true true / false
max-duration 选择视频时拍摄最长时长,单位秒 Number 60 -
camera 选择视频时摄像头方向 String back front / back
before-upload 上传前钩子,返回 true/false/Promise Function null -
before-remove 删除前钩子,返回 true/false/Promise Function null -
to-json 如果上传后返回值为json字符串,是否自动转为json Boolean true true / false
index 在各个回调事件中的最后一个参数返回,用于区别是哪一个组件的事件 String/Number '' -
custom-style 自定义根节点样式 String/Object {} -
custom-class 自定义根节点样式类 String '' -

Methods 方法

通过 ref 手动调用组件方法:

名称 说明 参数
upload 手动触发上传文件 -
clear 清空内部文件列表 -
reUpload 重新上传所有失败/未上传的文件 -
retry(index) 重新上传指定索引的文件 index: 文件索引
remove(index) 手动移除指定索引的文件 index: 文件索引
selectFile 手动触发文件选择 -
doPreviewImage(url, index) 预览图片 url: 图片地址, index: 索引
doPreviewFile(item, index) 预览/打开文件 item: 文件对象, index: 索引
addFiles(files) 添加文件到列表(配合 custom-choose 使用) files: 文件数组

Slots 插槽

名称 说明
addBtn 自定义选择文件按钮
file 自定义文件列表插槽

Events 事件

事件名 说明 回调参数
on-oversize 文件大小超出 max-size 限制时触发 (file, lists, name)
on-exceed 文件数量超出 max-count 限制时触发 (file, lists, name)
on-choose-complete 每次选择文件后触发 (lists, name)
on-choose-fail 文件选择失败时触发 (error)
on-uploaded 所有文件上传完毕触发 (lists, name)
on-success 单个文件上传成功时触发 (data, index, lists, name)
on-error 单个文件上传失败时触发 (res, index, lists, name)
on-change 单个文件上传状态改变时触发(无论成功或失败) (res, index, lists, name)
on-progress 文件上传过程中的进度变化时触发 (res, index, lists, name)
on-remove 移除文件时触发 (index, lists, name)
on-preview 预览文件时触发 (url, lists, name)
on-list-change 文件列表发生变化时触发 (lists, name)
on-choose 启用 custom-choose 时触发,用户可自定义文件选择逻辑 ({ accept, maxCount, currentFiles, index })
update:modelValue v-model 双向绑定事件,文件列表变化时触发 (lists)

说明:

  • lists - 当前组件内的所有文件数组
  • index - 当前操作的文件索引
  • name - 通过 props 传递的 index 参数,用于区分多个组件实例

文件列表对象结构

lists 数组中每个元素(UploadFileItem)的结构:

{
  // 基础信息
  url: string,           // 文件地址(上传成功后返回)
  path: string,          // 文件本地路径
  name: string,          // 文件名
  size: number,          // 文件大小(字节)
  fileType: 'image' | 'video' | 'file',  // 文件类型
  
  // 上传状态
  progress: number,      // 上传进度 0-100,100表示上传成功
  error: boolean,        // 上传失败标记
  response?: any,        // 服务器返回的数据
  
  // 媒体文件特有
  thumb?: string,        // 视频缩略图(仅视频)
  width?: number,        // 图片/视频宽度
  height?: number,       // 图片/视频高度
  duration?: number,     // 视频时长(秒)
  
  // 原始文件对象
  file?: any,            // 原始文件对象
  uploadTask?: UniApp.UploadTask  // 上传任务对象(用于取消上传)
}

文件类型说明

根据 accept 参数,支持以下文件类型:

accept 值 说明 自动检测的文件后缀
image 图片 png, jpg, jpeg, gif, webp, bmp, svg
video 视频 mp4, avi, mov, wmv, flv, mkv, rmvb, 3gp, m3u8
file 文件 根据 extension 参数或允许所有
media 媒体(图片+视频) 图片和视频后缀合集
all 所有文件 允许所有文件类型

注意:

  • 文件上传(accept=file)仅在 H5 和微信小程序支持
  • 媒体选择(accept=media)仅在微信小程序、App、头条小程序支持
  • 文件预览功能在 H5 体验最佳,其他平台可能受限
  • 通过自定义,你也可以实现不支持的平台特性功能

现在就开始使用 u-upload,让文件上传功能开发变得更加方便!更多内容请参考官方文档。

文档地址: uviewpro.cn/

开源地址:

iOS 27 发力 AI 修图,苹果也开始 AI 焦虑了

作者 苏伟鸿
2026年4月29日 18:30


今年的 iOS 27,将会 AI 味浓浓。

彭博社报道,苹果准备在今年的 WWDC 开发者大会上推出一套全新的 AI 修图工具,将会集成在 iPhone、iPad 和 Mac 的照片应用中。

沉寂了一年的 Apple 智能,将随着 iOS 27 的推出,再次回到聚光灯下。

两年前,苹果还公开表示不做 AI 修图功能,在竞争对手的步步紧逼之下,终于还是忍不住跟进了。

iOS 27:AI 无处不在

在 iOS 18 推出的 Apple Intelligence 工具集,苹果就已经允许用户利用 AI 简单消除照片中的物体,属于当下智能手机的标配功能。

苹果的对手已经走得更远。像是把「AI 修图」作为标志性功能的 Google,已经实现给人物更换完美表情、把人物加入合照,甚至重构整个画面背景的能力,整个 Android 阵营都在发力类似的功能。

图源:WIRED

在 iOS/iPadOS/macOS 27 中,苹果将在「照片」App 的编辑界面中,增加一个全新的「Apple Intelligence Tools」(Apple 智能工具集)模块,包含以下三个功能:

  • Extend(扩展),就是 AI 扩图的功能,允许用户在原始画面之外额外生成图像内容,比如拍摄一张旅游景点的地标图,然后用这个工具来填充周围的景色,用户可以自行控制扩图的范围和位置。
  • Enhance(增强),利用 AI 自动修图,有点像不能自定义的「豆包修图」。
  • Reframe(重构),主要运用于苹果的空间照片,允许用户在拍摄后改变视角,比如一张汽车照片可以从正面视角调整为侧面视角。这个功能将充分利用空间照片来自多个摄像头的结构数据。

不过,根据内部测试的员工透露,这些功能的开发并不算顺利,效果更复杂的「重构」和「扩展」不稳定,苹果很可能会推迟或砍掉这些功能的发布。

包括这个新的 AI 修图功能在内,iOS 27 系统的更新将会沿着「优化」和「AI」两个主旋律进行。

此前爱范儿已经多次报道,由于 iOS 26 引入了「液态玻璃」的全新设计语言,系统稳定性有明显下降,因此 iOS 27 将会聚焦在系统稳定性优化上,不仅要修复目前 iOS 26 的大量 Bug,还会提升设备的续航和性能表现,并持续修改液态玻璃的视觉效果。

其余的功能更新,则会集中在「AI」上。首先,苹果正在努力将 2 年前画饼的 AI Siri 正式实装 iOS 27,这也是 Apple 智能体验和未来苹果 AI 硬件战略的核心体验部分。

虽然已经「潜心打磨」两年,今年年初有内部人员向彭博社透露,AI Siri 的一些杀手级功能,例如语音控制 Siri 操作应用,测试结果并不理想。

这意味着,即使我们能在 iOS 27 见到 AI Siri 庐山真面目,它也大概率会是一个「技术预览版」,并且需要等待后续更新补充完整功能。

旧饼还没兑现,iOS 27 选择继续加码 AI 新功能。

苹果打算进一步将 Siri 改造为类似 ChatGPT 和 Google Gemini 那样的聊天机器人,届时 Siri 会有一个独立应用,用来对话和存储聊天记录。

苹果还计划在邮件、日历和 Safari 浏览器等第一方应用中,引入新的 Siri 引擎,实现更强的搜索和数据管理能力。

除此之外,苹果正在酝酿一个 AI 搜索引擎, 允许用户从网络搜索信息,生成综合的报告和信息列表,以及网页链接,作为 Safari 和 Spotlight 网络搜索。

在健康领域,苹果将结合 AI 推出「Health+」的订阅服务,利用 AI 智能体,对用户的身体数据进行个性化分析,并针对性推送真人医生录制的建议。

比起两年前那场 WWDC,iOS 27 这一大批 AI 功能,比目前的 Apple 智能还要更丰富不少。

FOBO 的风,还是吹到了库比提诺

2025 年 1 月, 苹果的软件主管 Craig Federighi 和营销高级副总裁 Greg Joswiak 接受了《华尔街日报》的专访,谈到了对 AI 的看法。

其中 Federighi 特别提到了「AI 修图」,解释为什么苹果只推出「消除」,而不是如同三星和 Google 一样做大量的功能:

对我们来说,重要的是帮助人们传播准确的信息,而不是虚构的「幻想」。

Google Pixel 的表情修正功能,图源:The Washington Post

苹果公司内部曾经针对「AI 修图」的尺度进行了长时间讨论,考虑到用户的高需求,苹果公司愿意迈出「小小的一步」,于是在 iOS 18 之中推出了「AI 消除」的功能。

而像是「图乐园」这种 AI 生图功能,苹果也做出了严格的限制,只能用于创作卡通图案,避免生成逼真的图像造成误导。

某种程度上,苹果的坚持已经开始松动,iOS 27 这个全新的「AI 扩图」功能,让 Apple 智能进一步介入照片的真实性。

回望两年前的那场 WWDC,Apple 智能以一个非常温和的形象问世,没有想象中的 Apple-GPT,苹果的很多尝试都显得谨小慎微,不具备改天换地的野心。

但 AI 产品的代际变化速度极快。别说两年前,两个月前都没人觉得 ChatGPT 是一个好用的文生图机器人,现在打开社交媒体 GPT Image 2 的作品已经铺天盖地。

两年没动弹过的 Apple 智能,自然「遥遥落后」。

作为终端厂商的苹果,原本拥有一个得天独厚的优势,能够一夜让自己的 AI 产品面向全球十亿用户推出。

只是,对于用户来说,Apple 智能不仅不算好用,更致命的是,它提供的价值,和用户的需求,有很大程度的错位,导致用户并不想用。

FOBO(Fear Of Becoming Obsolete,害怕被淘汰)的阴影,终究还是笼罩了苹果。

过去, 苹果可以决定什么功能值得出现;如今,它也必须回应用户已经习惯拥有什么,行业在发力什么。iOS 27 上这些曾被苹果否决的 AI 功能,本质上都是一次迟到的补课。

Siri 版 GPT 要做,AI 搜索引擎要做,系统应用也全部 AI 化,现在苹果也盯上了 AI 照片编辑,一个其他手机品牌很喜欢演示的功能。

苹果能不能把这些功能做好,又是另一个问题。

即使已经发布 2 年,Apple 智能的照片「消除」效果依旧不如人意,横向对比 Android 阵营显得更显落后,经常会出现消除不彻底、扭曲图像的问题。

全新「扩展」和「重构」功能则更复杂,内部已经反馈稳定性不佳——其实,我相信对于更多用户来说,会更希望苹果能把更实用的「消除」功能进一步完善好。

并且,AI 修图一直以来都争议缠身,特别是前两年的 Google Pixel,可以在一张真实照片上加入任何元素,实现以假乱真的效果,就引发了国外媒体对于「真实」和「伪造」的大讨论。

左图为实拍,右图经过 Pixel Magic Editor 编辑,图源:The Verge

苹果会尽量规避这种风险,目前看来,这些新功能的自由度相当有限,用户不能自定义修改的方向和指令。

面对行业趋势和用户需求,苹果也不得不松动和更改曾经的价值取向,现在的他们,其实还不知道自己要做什么样的 AI。

但这不仅是苹果的困惑,其实也是笼罩整个行业的迷思,最富含 AI 的 Google Pixel,也并非是我们期待的那台 AI 手机。

既然暂时难以重新扮演行业的引领者,那么在 AI 这场竞赛中持续调整步伐的苹果,至少还能先通过跟随,确保自己依然留在牌桌之上。

但我仍然期待,今年的六月,苹果能为我们带来惊喜。

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

首发实测|期待已久的HappyHorse 1.0,在千问能免费体验了

作者 张子豪
2026年4月27日 19:23

那个一度在 Artifical Analysis 的 AI 视频竞技场排行榜中登顶第一的视频生成模型 HappyHorse 1.0,我们终于能用官方版了,现在打开千问 APP 和千问创作Web端( c.qianwen.com ),直接就能用,甚至还有免费体验额度。

前段时间,一款名为 HappyHorse 1.0 的视频生成模型,悄然登顶权威 AI 评测平台 Artifical Analysis 的 AI 视频竞技场排行榜,引发社交媒体的纷纷议论。直到阿里正式认领 HappyHorse,谜团揭开,这匹快乐小马出自自家新成立不到一个月的 ATH 事业群。

今天,阿里公布了 HappyHorse 1.0 的体验渠道,千问官方首发灰测,千问 APP 和千问创作Web端都能直接使用。

移动端(千问 APP),我们只需将千问更新到最新版本,通过点击首页的「HappyHorse」胶囊,即可直接进入 HappyHorse 1.0 的生视频创作面板,并且千问还赠送了免费体验额度。

PC 网页版(千问创作 Web 端),针对有更专业创作需求的用户,可以通过浏览器打开 c.qianwen.com 登录使用。网页端每次生成消耗积分,综合对比下来,还是比较具有性价比的。

无论是文生视频还是图生视频,均支持最高 1080p 的视频分辨率。我们可以自由选择 16:9、9:16 或是 1:1 的视频宽高比,生成时长可选 5 秒、10 秒或15 秒,并且支持原生生成音频。

APPSO 第一时间拿到了体验资格,评测榜单的排名能说明结果,但是 HappyHorse 1.0 生成的视频,到底有什么优点,一起来看看我们的实测。

通过实测,能看到其实 HappyHorse 1.0 并没有在复杂的全能参考选项上做文章,而是将核心发力点放在了动作、声音、空间的自然度上,加上合理的镜头语言,和风格的准确还原,整体表现确实惊艳。

用一句指令,直接搞定运镜和故事板

大部分的主流视频模型,都会把镜头运动当做一个库,给用户来调用。所谓的镜头运动,更像是从这些库里,推进、拉远、旋转,随机挑一个运镜方式,并没有配合画面里正在发生的事情。

而镜头感作为视频最重要的一部分,往往一眼就能感受到明显的差距,但它本身又很难用具体数值来量化。

HappyHorse 1.0 的处理方式也表现得可圈可点,切换镜头的时机必须是服务于作品。情绪需要收紧的地方,镜头近一点;需要交代环境的地方,给我们全景;背后是一套有叙事逻辑的调度。

同样一个提示词,丢给多个模型生成的视频画面,可能都会偏向「固定机位」,人物站在中间,缺乏镜头调度。因为这样最不容易出错,但是给视频的观感又大打折扣。

HappyHorse 1.0 在生成的视频里,则是像一个懂行的摄影指导,各种大师级运镜,从全景到近距离跟拍马蹄的扬尘,再流畅切换到低角度仰拍拔枪的瞬间。

它打破了传统的 AI 视频生成模型「为了稳妥而选择平庸」的安全构图,用大量扎实的镜头调度,把这段追逐戏的动态张力,原原本本地拍了出来。

情绪和动作都有了层次感,微表情也能演戏

对于很多视频模型,人物动作是最难解决的问题。即便使用详细的参考生成,到了后半段还是容易出现变形,比如手指多一根、脸部模糊或者动作节奏突变。

但 HappyHorse 1.0 在这个硬指标上表现非常稳定,一段 5 秒的视频,人物动作从头到尾基本保持连贯,穿帮的频率明显更低。

举个具体的例子,我们用的提示词是一个穿着白色裙子的女生走在花海里,从画面的左边走到右边,镜头跟随,女生转动裙子,捧起一朵花闻。

HappyHorse 1.0 给的动作过渡非常自然,女孩在花丛中走路完全没有那些「太空步」的滑移,从她转动裙摆,到捧起花朵凑近鼻子,整个动作流程行云流水。

动作有层次感,人物的表情同样真实。我们生成了一个小朋友咬下酸柠檬的视频,从咬下柠檬的瞬间,到强烈的酸味,开始带来面部肌肉紧绷、五官皱起、紧闭双眼,再到酸劲儿逐渐过去,面部肌肉慢慢放松,最后茫然地重新睁大眼睛。

通过动作和表情,让人物的情绪更有层次感,HappyHorse 1.0 生成视频也更不容易让人出戏。

官方数据显示,HappyHorse 1.0 的内部 GSB(Good-Significant-Bad 人类偏好评分)是 Wan2.7 的 3 倍,动作流畅性和清晰度都进步明显。

对话听起来更像真人,环境音也开始参与叙事

除了画面表现,HappyHorse 在 AI 视频配音上的表现也比其他模型更出色。

大部分的 AI 视频配音,都有一个很难绕开的问题:听上去像在「念」,不像在「说」。

语气是平的,语调不跟着情绪走,两个人对话的时候,一方说话,另一方就在那里等着,没有反应,没有表情变化,像两个人在分别完成自己的任务。

HappyHorse 1.0 在这里的处理,是对白真的有情境感。语气和语调贴着画面里的情绪,惊讶的时候语调是对的,轻松的时候节奏是松的。多人对话的场景里,听的那一方也是自然,会有表情,有细微的肌肉反应,不是在发呆等下一句。

环境音也是一样的逻辑。书写声、翻页声、远处的背景音,这些细节在大多数视频模型里是缺席的,或者听上去是从音效库里随机抓来的。

HappyHorse 1.0 里,这些声音跟画面里正在发生的事情是对得上的,而且能参与情绪。在安静的场景里,出现一点纸张摩擦声,或许比大多数配乐都更容易让人有沉浸感。

还有一个比较小众但实用的能力:多语言的唇形同步,覆盖了普通话、粤语、英语、日语、韩语、德语、法语等语言。

输入中文文本生成人物说话的视频,嘴型就能跟上语音。这个能力的想象空间相当大,从短视频配音到虚拟主播,未来都会用得上。

不需要复杂的风格提示词,轻松拿捏经典影视剧风格

如果说前面关于镜头、动作和声音几点解决的是 AI 视频的硬件问题,即 AI 视频不能让人出戏;风格的还原,则是让最后的画面更有戏。它会开始用色彩、光影和质感,去建立属于创作者的美学氛围。

风格的添加也很讲究,不是套一层滤镜,或者一个打包好的 LUT 包,它也需要视频模型对不同美学风格的了解,以应用合适的风格化。

HappyHorse 1.0 在特定风格的还原上,细节非常扎实。各类经典影视剧的风格、老港片里胶片的颗粒感和偏冷的高光,我们在实测的生成结果里面都能看到。

无论是老水浒/三国画风那种粗粝写实的历史厚重感、光影迷离的经典港风,还是强调高反差冷峻光影的美剧质感、主打细腻柔光的韩剧氛围,它都能精准拿捏。

如果你是个对画面质感有追求的创作者,非常推荐去千问里亲自感受一下这种「导演级」的美学控制力。

AI 视频赛道需要一匹黑马

告别了动辄半天的视频生成排队,一个 Video Arena 榜单第一的模型,现在不仅直接放到了手机 App 里随手可用,还给了免费体验额度,千问这波实在是给力。

回头看 HappyHorse 1.0的这几个特点,动作不穿帮、镜头有语言感,解决了 AI 内容质量的可预期性,让我们不用再抱着「抽卡」的心态,去体验 AI 视频生成。

对白自然、真实的环境音、还有精准的风格化还原,更是让我们和创作者少了大量的后期修补成本,不需要在多个工具之间来回倒腾。

如果把这种极低门槛、高容错率的生成能力放到具体的商业语境中,价值是显而易见的。

对于新媒体运营、短剧导演或是电商营销团队而言,过去需要庞大后期团队和高昂拍摄预算才能完成的分镜预演、概念设计或视觉短片,现在只需在手机或电脑上输入指令就能快速落地。在千问里,一个人就是一支高效的视听制作团队。

▲现在我们在千问里,就能得到一段真实的虚拟主播视频

过去一段时间,视频生成赛道的竞争逻辑是「谁的模型更强」——更高的分辨率、更长的时长、更复杂的物理模拟。

拼的是参数和算法的技术竞赛,但我们真正卡住的地方很少是因为「模型做不到」,大多数时候是「做到了但用不起或用不到」,等待时间太长、声画要分开处理、动作稳不稳全靠运气,每一个环节的摩擦都在把视频生成挡在专业用户和 AI 超级创作者之外。

而这一次,千问不仅省去了我们在不同工具之间切换的折腾,把最顶级的视频生成能力直接放到了最熟悉的对话框里,更借助底层模型的实力,把这些创作摩擦一个个彻底抹平了。

千问现在是工作、学习、生活和创作中全能 AI 助手

HappyHorse 无疑是一匹强劲的黑马,他是阿里新成立的 ATH 事业群,在模型能力、平台分发、具体应用这条完整链条上的一块关键拼图;在千问首发灰测后,链条开始跑起来了。

从帮助用户解决日常问题、提升工作学习效率的文本对话,到如今整合了极高水准的 AI 生图与视频能力,千问的进化路径已经非常清晰:它正在打破「生活提效」与「专业创作」的壁垒。

通过一次次的功能迭代,千问正将顶级的算力平民化,真正从一个简单的问答工具,蜕变为一个覆盖用户全场景的「全能型 AI 助手」。

作为普通人,我们或许不需要关心背后复杂的算法架构,因为最好的技术,已经通过千问以最顺滑的方式装进了你的手机里。

现在,轮到大家上场了。

如果你也想体验 HappyHorse 1.0 强大的视频生成能力,千问还同步开启了「天马行空」挑战赛。一共四大 AIGC 视频赛道,20 万现金奖池等大家来拿。

直接前往千问 App 或千问创作 Web 端,用灵感在这个没有门槛的新画布上,真正「天马行空」一次。

*文章内视频播放可点击该链接预览*

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

一个 iOS 埋点 SDK 从 0 到 1,再到真实项目接入打磨

2026年4月23日 18:56

我最近把一套已经在线上跑通的项目内埋点代码,抽成了一套可复用的 iOS SDK。原本我以为难点只是“把代码搬出去”,后来才发现,真正难的是哪些能力该留在 SDK、埋点上报发送请求到底要不要sdk来接管、日志怎么做才方便进行测试验证、文档和版本号怎么跟上。这篇文章分享的是这套 SDK 被真实接入反馈一步步打磨出来的过程。


最开始我以为,做这套埋点 SDK,就是把项目里那套已经跑通的代码抽出来就可以了,后来发现没那么简单。

真正麻烦的地方,是代码抽出去之后才出现的:

  1. 哪些能力应该放进SDK
  2. 哪些逻辑必须留在业务项目
  3. SDK 要不要负责埋点上报发送请求
  4. 日志到底是给开发看,还是给测试和产品验收使用
  5. 文档和版本号没跟上时,同事会不会直接集成失败

因为我后来确定了一件事:把埋点代码抽成 SDK,难点从来不是“把代码搬出去”,而是让它在接入、调试、验证、埋点上报这些环节里都真的可用。

一、为什么我会做这个 SDK

起因其实很简单,我手上有一个已经在线上跑通的 iOS APP 项目,里面已经有一套比较完整的埋点能力:

  1. 自动采集公共事件属性
  2. 自动补一组固定用户属性
  3. 统一时间格式
  4. 固定首次安装时间和安装时区
  5. 构建事件请求
  6. 构建用户属性请求
  7. 埋点上报发送请求
  8. 失败后自动重试
  9. 打调试日志

于是,领导就给我提出一个需求:让我封装一个埋点sdk,把固定用户属性和公共事件属性都封装在sdk中,让sdk内部自动获取这些属性值,iOS同事在其他项目中进行埋点上报的时候,就不需要再单独写一套固定用户属性和公共事件属性上报的代码了,直接使用sdk的能力就可以了。

所以,这次的目标很明确,是把这些已经被项目验证过的能力,封装成一个其他 App 项目也能对接的 SDK。

二、原来那套代码为什么不适合直接复用

我最开始手上拥有的,是一套项目里我写好的埋点管理代码。

这种埋点管理类在业务项目里很常见,一开始也确实好用,因为它把所有事都接住了:

  1. 事件名
  2. 公共属性
  3. 用户属性
  4. 时间格式
  5. 请求参数构建
  6. 请求发送
  7. 失败重试
  8. 日志它

以上8点它全部都管。截一小段原来的调用入口,就能看出这种写法的特点:

func track(_ eventName: SC_MQ09EventName,
           properties: [String: Any] = [:],
           timestamp: Date? = nil) {
    let resolvedTimestamp = timestamp ?? Date()
    var payload = buildEventPayload(
        eventName: eventName,
        properties: properties,
        timestamp: resolvedTimestamp
    )

    guard JSONSerialization.isValidJSONObject(payload) else {
        SuperCoderNetLog("[MQ09] invalid payload for \(eventName.rawValue): \(payload)")
        return
    }

    do {
        let data = try JSONSerialization.data(withJSONObject: payload, options: [])
        routeEventPayload(
            payload: payload,
            payloadData: data,
            allowRetryStore: true,
            eventName: eventName.rawValue
        )
    } catch {
        SuperCoderNetLog("[MQ09] encode failed: \(error.localizedDescription)")
    }
}

这段代码本身没有错,问题在于,它已经同时在做几件事:决定事件时间、构建请求参数、校验 JSON、准备失败重试需要的数据、再把埋点上报请求发送出去。

在一个项目里,这样写能很快推进,但一旦你想复用到别的项目,就会发现它太像“项目现场代码”,而不是一层可以被其他 App 直接依赖的通用能力。

这种写法在“单项目快速推进”阶段没问题,但一旦你想跨项目复用,它马上就会暴露两个大问题。

第一,职责太杂。

它既有通用能力,又有业务语义。比如某些页面事件、某些业务字段、某些页面触发时机,这些本来只属于当前项目,但也被混进了同一层埋点管理代码。

第二,边界不清。

你很难回答一个问题:

到底哪些是“埋点 SDK 应该负责的”,哪些只是“当前这个业务项目碰巧这么写了”。

这也是我后来感受最强的一点:

项目里能跑通,不代表它已经具备跨项目复用条件。

三、我怎么划 SDK 和业务项目的边界

真正开始封装 SDK 之后,我先做的不是写代码,而是先把边界想清楚。

我想清楚了以下3点:

1. 必须放进 SDK 的,是稳定的基础能力

比如这些:

  1. 公共事件属性采集
  2. 固定用户属性采集
  3. 时间格式统一
  4. 安装时间与安装时区
  5. 事件请求参数构建
  6. 用户属性请求参数构建
  7. 可选的埋点上报发送能力
  8. 失败重试
  9. 日志输出

这些东西不依赖某个具体页面,也不属于某个特定业务,很适合收进 SDK。

2. 必须留在业务项目里的,是具体业务逻辑

比如:

  1. 某个页面的事件名
  2. 某个业务字段怎么算
  3. 哪个时机触发埋点
  4. 哪组字段是这个业务独有的

这部分如果硬塞进 SDK,SDK 很快就会变成“这个项目专用库”,复用价值就没了。

3. 埋点上报发送能力必须做成可选

我同事跟我说,一般SDK能够发送请求上报埋点,他们都会选择直接用SDK上报,但我在做的过程中,还是觉得做成可选吧,因为不一定所有项目都愿意把网络请求交给 SDK。

有的项目想要的是:

  1. SDK 帮我构建参数
  2. 我自己发请求

有的项目则希望:

  1. SDK 帮我构建参数
  2. SDK 直接把请求也发了

所以我最后没有把发送写死,而是保留了两条路:

  1. 标准 SDK 接法:直接 track / setUserProperties
  2. 直接发送完整请求参数:项目先把所有参数组合好,再交给 SDK 发

标准接法的入口最后被压得很薄:

public func track(
    eventName: String,
    properties: [String: Any] = [:],
    timestamp: Date? = nil,
    eventType: String = "track"
) {
    let rawParams = ZZHAnalyticsJSONSanitizer.dictionary(properties)
    let payload = makeEventPayload(
        eventName: eventName,
        properties: rawParams,
        timestamp: timestamp,
        eventType: eventType
    )
    sendPayloadIfPossible(
        payload,
        endpointType: .event,
        startLogContext: .event(eventName: eventName, params: rawParams)
    )
}

业务方只需要告诉 SDK:我要发哪个事件,带哪些业务参数,至于公共字段怎么补、时间怎么格式化、请求怎么发、日志怎么打,都留在 SDK 里面处理。

这个决定看起来只是 API 设计,实际上后来直接影响了后面埋点日志输出功能的业务逻辑。

我也没有直接把旧的上报方式整条推翻,这轮更稳的做法其实是:

  1. 先让 SDK 在原来已经在跑的那条上报路径旁边,并行对照一段时间
  2. 先看 SDK 组合出来的参数,和项目里原来那套上报逻辑是不是一致
  3. 确认没问题以后,再正式切到只走 SDK 这一条上报路径

这件事现在回头看也特别值得记下来。

因为复杂项目里,真正危险的不是代码抽得慢,而是你一上来就直接替换项目里正在使用的那条上报路径,这样一旦 SDK 和旧逻辑有没对齐的地方,往往要到真实验收时才会暴露出来。

四、真正让这个 SDK 变难的,不是封装,而是真实项目接入后的反馈

如果这套 SDK 只停留在“我本地能跑”,其实没什么特别值得写的。

真正让它有工程价值的,是iOS同事在他的项目中接入时遇到的SDK出现的各种问题。

1. distinct_idaccount_id 应该怎么传

一开始最容易掉进去的坑,是想把这两个用户标识字段设计得很“完整”,但后来对着真实项目接进去时,我才发现这不只是字段怎么传的问题。

distinct_id 这条比较清楚:

  1. distinct_id == device_id
  2. 这个值必须由接入方自己提供
  3. SDK 不再内部默认生成

真正麻烦的是 account_id

一开始我把它理解成“有就带,没有也不影响上报”,代码里也确实是这么处理的。

但真实接入时,很快就暴露出一个更具体的问题:

很多 App 都是先初始化 SDK,后面才从 Adjust SDK 异步拿到 adid

也就是说,问题不只是“account_id 要不要传”,而是:

SDK 初始化完成的时候,account_id 很可能还拿不到。

后来产品把规则也确认得更明确了:

  1. distinct_id 必须有
  2. account_id 的值就是外部拿到的 adjustid
  3. account_id 不是初始化时必须就有,但外部拿到 adjustid 后要立刻传给 SDK
  4. 传进去以后,后续标准事件上报和用户属性上报都要自动传参 account_id
  5. account_id 还要作为用户属性,再主动补报一次 user_setOnce

一开始我以为这是字段传值规则的问题,后来接入时才发现,真正麻烦的是 SDK 已经初始化好了,adjustid 却还没回来。

所以后面真正的修改方式,不是继续讨论 account_id 到底算“可选”还是“不可选”,而是把 SDK 补成初始化完成后也能继续更新 account_id

最后 SDK 对外多补充了一个明确方法:

Adjust.adid { adid in
    guard let adid, adid.isEmpty == false else { return }
    ZZHAnalyticsSDK.shared.setAccountID(adid)
}

也就是说,这件事最后真正定下来的,不是一句“account_id 可选”,而是:

  1. distinct_id 一开始就由项目传进来
  2. account_id 等 Adjust 返回后,再立刻传给 SDK
  3. 标准上报路径后续自动传参 account_id
  4. SDK 主动补一次 user_setOnce 用户属性 account_id

这比我一开始那种“先把规则写简单一点再说”的理解,要更接近真实项目接入。

2. 用户属性更新方式为什么要改成枚举

一开始用户属性更新方式可以传字符串,这在设计上很灵活,但真实接入时很容易变成:

  1. 表面看起来统一
  2. 实际每个项目都可能传不同字符串
  3. 最后 SDK 很难保证大家传的是同一套规则

所以后来我只保留了两种明确的写法:

  1. user_set
  2. user_setOnce

这个改动看起来不大,但它背后的意思是:

SDK 不是只负责“让你能传”,还要尽量避免接入方传错、传乱。

3. 失败重试为什么不能只停在当前进程内

一开始 SDK 只有自动重试 2 次。

这对临时网络失败来说够用,但接入方很快会问一个问题:

如果这次重试两次都失败,下次 App 重启以后怎么办?

这个问题一出来,我就知道,这不再只是“重试几次”的问题,而是“第一次生成的请求内容要不要保存下来”的问题。

因为一旦你要支持 App 重启后继续重发,就意味着:

  1. 这次请求的 bodyData 不能丢
  2. 不能下次再重新组合一遍参数
  3. 否则字段和时间可能会和第一次不一致

所以后来这一块的核心原则就变成:

重试永远基于第一次生成的请求内容,而不是重新构建请求参数。

这也是我觉得很值得写出来的一条工程经验。

很多人会默认“失败了再组合一次参数”,但埋点这种东西,真这么做,最后发出去的内容就可能和第一次不一样了。

而且这件事后面我越看越觉得不能偷懒,因为变化的根本不只是接口的参数 time

如果你让 SDK 失败后重新组一次参数,可能一起变掉的还有:

  1. 当时的网络状态
  2. 当时的权限状态
  3. 当时的安装相关字段
  4. 那次请求真正想表达的时间点

所以后来我对这条原则的理解就更明确了:

埋点请求一旦生成,就应该尽量把它当成那一刻的快照。

4. ta_app_install 事件上报的时间后来为什么还要单独修改

这也是产品验收时发现的一个问题。

一开始我会默认觉得:

  1. 普通事件上报接口传参 time 用当前时间
  2. 这是很自然的做法

这对绝大多数事件都没问题。

ta_app_install 不一样。

因为产品验收时看的不只是传参的 time,还会一起看:

  1. #install_time
  2. install_ts_bj
  3. install_ts_utc
  4. install_ts_time

这几个时间字段本质上都应该指向同一个安装时间点。

当前项目之所以一直没出问题,是因为业务层本来就主动把安装时间传给了这条事件。

但 SDK 标准接法如果只是:

ZZHAnalyticsSDK.shared.track(eventName: "ta_app_install")

旧逻辑会直接把发起这次上报时的当前时间,写进这个事件的 time 字段。

这样一来,这个事件里的 time,和安装相关字段就不是同一个时间来源了。

这个问题特别能说明一件事:

把代码封装成 SDK,只是第一步,等产品真的开始逐个字段检查时,你才会知道还有哪些地方没处理对。

后来的修法也很克制:

  1. 普通事件还是继续用当前时间
  2. 只有 ta_app_install 且外部没传 timestamp 时,才默认改用 SDK 保存的安装时间

这件事让我后面更确定,SDK 真正难的不是“第一版怎么设计”,而是:

当产品拿着真实字段来验的时候,你能不能只改那一个真正有问题的地方,而不是顺手把整套时间逻辑都推倒。

五、埋点日志系统是怎么一步一步优化和完善的

如果说前面这些问题还在解决“SDK 能不能用”,那埋点日志系统解决的是另一件事:

SDK 能不能帮助测试和产品快速确认埋点参数有没有传对。

1. 最开始的日志,其实只对 SDK 开发者有用

最开始 SDK 里的日志更像网络请求调试日志:

  1. 打印发送过程
  2. 打印状态码
  3. 打印成功失败

但这类日志有个问题:

做 SDK 的人能看懂,测试拿去检查埋点参数就很难受。

因为测试真正关心的不是网络请求内部过程,而是:

  1. 这次到底发到哪个 URL
  2. 请求头是什么
  3. 请求参数是什么
  4. 服务端响应了什么
  5. 到底成功还是失败

所以后来日志被拆成了两类:

  1. 发起日志
  2. 结果日志

代码里也尽量保持这个拆分方式:

public func send(snapshot: ZZHAnalyticsRequestSnapshot,
                 completion: @escaping (Bool) -> Void) {
    var request = URLRequest(url: snapshot.url)
    request.httpMethod = "POST"
    request.httpBody = snapshot.bodyData

    #if DEBUG
    ZZHAnalyticsDebugStartLog(Self.startLog(for: snapshot), snapshot: snapshot)
    #endif

    URLSession.shared.dataTask(with: request) { data, response, error in
        if let error {
            #if DEBUG
            ZZHAnalyticsDebugLog(Self.failureLog(for: snapshot, error: error))
            #endif
            completion(false)
            return
        }

        guard let httpResponse = response as? HTTPURLResponse,
              (200...299).contains(httpResponse.statusCode) else {
            #if DEBUG
            ZZHAnalyticsDebugLog(
                Self.responseLog(for: snapshot, response: response, data: data, success: false)
            )
            #endif
            completion(false)
            return
        }

        #if DEBUG
        ZZHAnalyticsDebugLog(
            Self.responseLog(for: snapshot, response: httpResponse, data: data, success: true)
        )
        #endif
        completion(true)
    }.resume()
}

这段代码的重点,不是“加了几行日志”,而是把日志按使用场景拆开:请求发出去之前,先打印一条发起日志,让人能看到这次准备发什么;请求回来之后,再打印一条结果日志,让人能看到这次到底有没有成功。

2. 发起日志解决“准备发什么”的问题

发起日志最终打印的是:

  1. 时间
  2. 事件名或者用户属性更新方式
  3. URL
  4. Headers
  5. Params

它解决的问题很明确:

它能够让开发者、产品、测试清晰地知道埋点是否有正确发起上报。

3. 结果日志解决“最终发得对不对”的问题

结果日志则继续保留:

  1. URL
  2. Headers
  3. Params
  4. StatusCode
  5. Response
  6. Success

它解决的是另一层问题:

埋点上报请求最终到底成功没有,服务端返回了什么。

4. 为什么后来还要加埋点日志系统的代理方法

做到这里,我以为已经完成任务了。

后来同事那边又提了一个很真实的诉求:

他们项目里本来就有一个悬浮日志窗口,测试和产品会直接在 App 里看埋点日志,如果 SDK 内部只把日志打到 Xcode 控制台,对他们来说是远远不够的。

这时我才意识到,日志不只是“打印出来”,还得“送出去”。

于是后来又补了一层日志代理:

  1. SDK 在 Xcode 打什么
  2. 代理方法就原样返回什么
  3. 接入方拿到以后,直接塞进自己的日志窗口

最终对外暴露的协议是这样的:

public protocol ZZHAnalyticsLogDelegate: AnyObject {
    func analyticsSDK(
        _ sdk: ZZHAnalyticsSDK,
        didReceiveEventStartLog message: String,
        eventName: String,
        params: [String: Any]
    )

    func analyticsSDK(
        _ sdk: ZZHAnalyticsSDK,
        didReceiveUserPropertyStartLog message: String,
        updateType: String,
        params: [String: Any]
    )

    func analyticsSDK(_ sdk: ZZHAnalyticsSDK,
                      didReceiveEventResultLog message: String)

    func analyticsSDK(_ sdk: ZZHAnalyticsSDK,
                      didReceiveUserPropertyResultLog message: String)
}

这个代理方法不复杂,但它把两件事拆清楚了:一边是 SDK 原样日志 message,另一边是业务自己可能想再打印一条简洁日志用的 eventName / updateType / params

这一层能力看起来很小,但它把日志从“开发调试工具”变成了“测试和产品也能直接用的快速确认埋点参数有没有传对的工具”。

做到这一步,我自己的总结是:

很多 SDK 日志的问题,是日志只对 SDK 开发者有用,对测试和产品没有用。

六、同一个 params,在不同接法下代表的内容不一样

这是我在真实项目接入时遇到的一个具体问题。

一开始我把发起日志代理设计成:

  1. message:完整日志原文
  2. params:给接入方自己打印一条简洁发起日志

看起来很合理,但接入后我发现,同样叫 params,在不同接法下代表的内容并不一样。

1. 标准 SDK 接法

如果你走的是:

track(eventName:properties:)
setUserProperties(...)

params 很好理解,就是业务方最开始传进来的参数。

2. 直接发送完整请求参数

如果你走的是:

sendEventPayloadSnapshot(payload)
sendUserPropertyPayloadSnapshot(payload)

那 SDK 拿到的已经是完整的请求参数了。

这时 SDK 内部根本没法再判断:

  1. 哪些是页面最开始传进来的业务参数
  2. 哪些是 SDK 自动获取的公共、固定字段

所以这时发起日志里的 params,默认只能是请求参数里现成的 properties

而当前我这个项目,真实主路径其实更接近这一种。

也就是说,当前 SuperCoder / MQ09 不是一开始就完全走标准 track / setUserProperties,而是更多时候先在项目里把完整请求参数组装好,再交给 SDK 发送上报请求。

这也是为什么我后面会专门把这一点写进接入文档里。因为如果不把“当前项目接法”和“标准 SDK 接法”拆开讲,同事看到日志里的 params,会很容易误以为 SDK 自己把业务参数改掉了。

这个区别后来我专门写进了使用文档中,以免iOS同事理解有误。

3. 固定用户属性自动补发又是一个特例

后面又出现了第三种情况。

SDK 会自动补发一组固定用户属性,比如:

  1. country
  2. install_ts_bj
  3. install_ts_utc
  4. install_ts_time
  5. install_ts_time_timezone

这组属性不是业务方手动传的,但测试又希望在发起日志里直接看到它们。

所以最后我又单独给它做了一个特例:

  1. 普通发起日志:params 继续代表外部原始入参
  2. 固定用户属性自动补发:params 特例代表 SDK 这次自动补发的固定字段

这件事说明了一个问题:

同一个参数名,看起来一样,但在不同使用方式下,代表的内容可能不一样。

这也是 SDK 设计里很容易忽略、但真实接入时很容易暴露的问题。

七、文档、tag、Pod 接入,为什么也是 SDK 工程的一部分

如果只看代码,这套 SDK 其实已经“能用了”。

但我觉得,真正决定它能不能被其他同事真正的在项目中使用,不只是代码。

还有以下这些东西:

  1. 使用文档写得是不是够直白
  2. 示例代码是不是和真实接法一致
  3. 版本号和 tag 有没有同步
  4. 其他同事复制文档接入时,会不会直接编译报错

我这轮就真实踩到了几个这样的坑:

  1. 使用文档写了新能力,但 tag 还是旧版本,同事一装就报错
  2. 使用文档里某些词太偏内部表达,比如“快照模式”,接入同事不好理解
  3. 日志代理示例里,类型写法稍微不直白,同事就可能写成错误的双层类型名

这些问题不容小觑,是 SDK 工程化本身的一部分。

因为对接入方来说,他们真正关心的是:

  1. 我怎么接
  2. 我怎么调
  3. 我怎么验
  4. 我装下来的这个版本,到底是不是文档里写的那个版本

所以,SDK 不是“代码抽出来”就结束了,它还要能被别人低成本接上。

而且到后面我还发现,低成本接上这件事,其实也分两层。

第一层是:

  1. README 写清楚
  2. tag 发对
  3. Pod 依赖能装上

第二层是:

  1. 同事能不能只写一个包名
  2. pod install 的时候会不会还要处理私有仓库认证

这轮我其实只把第一层基本收住了。

也就是说,SDK 代码和接入文档已经比一开始成熟很多了,但分发体验还没有完全走到最理想的形态。现在依然更接近“私有 git + tag”的方式,而不是那种更标准、更省心的私有 Specs 仓接法。

这也让我后面更确定一件事:

SDK 的工程化,不只是代码和 README,还包括分发基础设施到底有没有跟上。

八、这轮工作最后让我确定的几条原则

最后,总结 6 个我这次做 SDK 后真正踩出来的经验。

1. 项目里能跑通的代码,不一定适合直接做成 SDK

很多时候,项目里的代码只是刚好满足当前业务,想让其他项目也能用,还需要重新拆清楚:哪些放进 SDK,哪些留在项目里。

2. 发送能力最好可选,不要默认 SDK 接管一切

不是所有项目都希望 SDK 直接发请求。让 SDK 同时支持“构建参数”和“直接发送”,接入成本会小很多。

3. 重试一定要基于第一次生成的请求内容

埋点怕的不是失败,而是失败后重新组参数,导致最后发出去的内容和第一次不一样。只要涉及重试,就尽量保存第一次生成的请求内容。

4. 日志要方便测试和产品检查参数,而不只是方便 SDK 开发者调试

对 SDK 开发者好用,不代表对测试好用。日志里能不能一眼看到 URL、参数、响应和成功、失败,才决定这套日志有没有价值。

5. 文档、版本号和 Pod 接入方式,也要一起维护

文档不准、tag 不同步、示例代码不对,都会让接入方直接踩坑。SDK 想让别人顺利接入,就不能只管代码。

6. SDK 是靠真实接入反馈一点点打磨出来的

我这轮最大的感受就是这个,很多一开始觉得“设计得挺好”的地方,最后都是在真实项目接入、同事反馈和测试检查参数时,才暴露出问题。

一个埋点 SDK 真正的完成度,不取决于它能不能发请求,而取决于它能不能被其他项目低成本接入、被测试高效验参、被版本稳定发布。

这可能也是我这轮工作里,最值得留下来的那部分。

别被系统绑架:SwiftUI List 替换背后的底层逻辑

2026年4月22日 19:11

在这里插入图片描述

凌晨三点,楼里只剩空调低鸣。林屿坐在工位前,盯着 SwiftUI 里的 List,像盯着一个多年的老朋友。这个老朋友不坏,甚至称得上可靠。可今天,他忽然觉得不对劲了。页面能跑,交互也顺,但那层说不清的“高级感”,总像隔着一层雾,伸手能碰到,握住却没有。问题出在哪?他顺着代码往下摸,摸到最后,才发现真正的悬念从来不在样式,而在工具选错了场子。

🧭 在 SwiftUI 中构建 List 的替代方案

每当你打算在 SwiftUI 里做一个可滚动页面时,第一反应往往是用 List。这很正常。List 名声在外,又是系统组件,出手就带着几分正统气息。

在这里插入图片描述

但话说回来,它并不总是最合适的选择

List 最擅长的,是展示那种整齐划一的统一数据。比如邮箱列表、待办事项、联系人,这类内容结构规整,节奏统一,List 处理起来得心应手。

可如果不是这种场景,画风就变了。
对于其他更灵活、更讲究布局和视觉层次的页面,ScrollView 搭配 lazy stack,几乎总是更好的方案。

在这里插入图片描述

这篇文章要讲的,就是如何在 SwiftUI 中构建一个自定义的可滚动容器,让我们对 look and feel 拥有真正精细、可控的掌握力。


⚙️ 先把底牌摊开:ScrollView 这几年,已经不是昨日黄花

先说一句实在话。

过去几年里,SwiftUIScrollView + lazy stacks 的性能做了相当大的改进。它早已不是那个“能用,但心里发毛”的角色。今天的它,已经足够稳,足够快,也足够灵活。

在这里插入图片描述

所以,如果你展示的不是那种几十万条统一数据,比如邮箱、待办清单这种超大规模列表,那么:

ScrollView is a way to go.

这句话轻描淡写,实际上意味深长。

它的意思不是 “List 不行”,而是:
如果你的页面不依赖大规模统一数据的复用机制,那你就没必要把自己绑死在 List 上。

工具有长处,也有边界。看不见边界,迟早吃亏。


🫀 CardioBot 的现状:已经不错,但还不够狠

这是林屿自己独立开发的 CardioBot app。

在这里插入图片描述

上面有 4 张截图:前两张是当前版本,后两张是林屿想达到的效果。

现在这款 app 使用的是标准 List。而且说句公道话,它现在的界面观感并不差,作者自己也很喜欢它目前的 look and feel

但人一旦开始较真,就回不了头。

在这里插入图片描述

林屿决定重新审视自己的 UI。目标并不激进,不是要把界面改得面目全非,而是要做到两件事:

  • 保留 iPhone 用户熟悉、直观、可识别的感觉
  • 让 UI 再精致一些,再讲究一些,再“骚”一点,但绝不轻浮

这类优化最难。它不是“重做”,而是“进一寸”。可往往,真正拉开差距的,就是这一寸。


🧱 为什么这里的 List 已经不再对味了

CardioBot 展示的是不同类型的健康指标。问题在于,这些内容不是统一数据集,而是一组风格不同、职责不同的内容块。

林屿用了多种 card 类型,比如:

  • HeroCard
  • TintedCard
  • RegularCard

看到这里,症结就露出来了。

如果数据并不统一,那么使用 List 去做 cell recycling,其实就没多大意义。List 的一身本事,主要是为海量、统一、标准化的数据而生。可这里是一桌散席,不是整齐列队。

在这里插入图片描述

林屿当然也试过继续依赖 List
他可以通过一些 list-specific view modifiers 做出接近目标的样子,比如:

  • listRowBackground
  • listItemTint
  • listRowInsets

它们在 List 内部确实很好使,像一把趁手的短刀。

可惜,刀再锋利,也有出鞘范围。
这些 list-specific view modifiers 一旦离开 List view,立刻失灵。也就是说,这些能力是 List 私有的,不可外借。

在这里插入图片描述

结果就是:
你想在 List 之外维持相同风格,就必须额外补样式。补来补去,补成了拆东墙补西墙,最后不是代码发虚,就是视觉跑偏。

这就不是“能不能做”的问题了,而是“做得值不值”。


🪄 真正的转机:Container View APIs

幸运的是,SwiftUI 后来引入了 Container View APIs

这套 API 看起来安安静静,实际上杀伤力很大。它允许我们把 SwiftUI 视图先拆解,改点东西,再重新组合回来。

这意味着什么?

在这里插入图片描述

意味着你不再只是“使用容器”,而是可以“制造容器”。
你可以借助 Container View APIs 构建可复用的容器视图,像 ListForm,甚至任何高度自定义的东西。

说穿了,这是一种权限的变化。
以前你在用系统给的积木。
现在你很Happy的开始自己烧砖。


📦 第一块积木:ScrollingSurface

由于林屿的 app 中每个页面都采用 ScrollView 加 lazy stack,所以他提炼出了一个统一类型:ScrollingSurface

public struct ScrollingSurface<Content: View>: View {
    public enum Direction {
        case vertical(HorizontalAlignment)
        case horizontal(VerticalAlignment)
    }

    let direction: Direction
    let spacing: CGFloat?
    let content: Content

    public init(
        _ direction: Direction = .vertical(.leading),
        spacing: CGFloat? = nil,
        @ViewBuilder content: () -> Content
    ) {
        self.spacing = spacing
        self.direction = direction
        self.content = content()
    }

    public var body: some View {
        switch direction {
        case .horizontal(let alignment):
            ScrollView(.horizontal) {
                LazyHStack(alignment: alignment, spacing: spacing) {
                    content
                }
                .scrollTargetLayout() // 告诉滚动系统:这里是目标布局区域
                .padding()
            }
        case .vertical(let alignment):
            ScrollView(.vertical) {
                LazyVStack(alignment: alignment, spacing: spacing) {
                    content
                }
                .scrollTargetLayout() // 垂直方向同理
                .padding()
            }
        }
    }
}

他的意思很直接:
ScrollingSurface 本质上就是对 ScrollViewLazyVStack / LazyHStack 的一个简单包装。根据方向不同,切换成垂直或水平滚动容器。

在这里插入图片描述

但别小看这个“简单”。

为什么它值得单独抽出来?

因为它做了三件很重要的事:

  • 统一了页面根结构
  • 统一了滚动方向的表达方式
  • 统一了 spacing 和 padding 的布局语义

林屿会把 ScrollingSurface 作为 app 每个页面的 root view。
这不是偷懒,这是定规矩。

在这里插入图片描述

规矩一旦立住,后面的样式和结构才能不乱套。


🃏 第二块核心积木:DividedCard

接下来,UI 里的关键原语出现了:DividedCard

它最重要的地方,在于使用了 Group(subviews:),这是 SwiftUI Container View API 的一部分。

public struct DividedCard<Content: View>: View {
    let content: Content

    public init(@ViewBuilder content: () -> Content) {
        self.content = content()
    }

    public var body: some View {
        Group(subviews: content) { subviews in
            if !subviews.isEmpty {
                VStack(alignment: .leading) {
                    ForEach(subviews) { subview in
                        subview

                        if subviews.last?.id != subview.id {
                            Divider()
                                .padding(.vertical, 8) // 在每个子视图之间插入分隔线
                        }
                    }
                }
                .padding()
                .frame(maxWidth: .infinity, alignment: .topLeading)
                .background(
                    .regularMaterial,
                    in: RoundedRectangle(cornerRadius: 32) // 给整体包上圆角卡片背景
                )
            }
        }
    }
}

Group(subviews:) 到底妙在哪?

这招很关键。
它允许我们把通过 @ViewBuilder 传进来的视图拆成一个个子视图

在这里插入图片描述

换句话说,你不再只能把一整坨内容当黑盒来用,而是能看到里面每个子项,并逐个处理它们。

林屿在 DividedCard 里干的事情很漂亮:

  1. 先把内容拆开
  2. 遍历所有 subviews
  3. 在每个子视图后面加上 Divider,但最后一个不加
  4. 最后把整个结构包进一个带圆角的材质背景里

结果就是:
一组原本只是“连续排列的内容”,立刻拥有了卡片感、分组感和边界感。

这一手为什么重要?

因为很多产品界面都存在这样的结构:

  • 一张卡片里放多个入口
  • 每个入口既独立,又需要视觉连续
  • 中间要有分隔,但不能显得生硬

以前你可能要在每个地方重复写 Divider、padding、background、cornerRadius`,写多了就腻,改起来更烦。

在这里插入图片描述

现在不同了。
DividedCard 把这套规则提炼成了一个可复用 primitive

这就是架构的味道:
不是“这页看着对”,而是“以后都能对”。


🧩 第三块积木:SectionedSurface

另一个很有意思的 UI primitive,是 SectionedSurface

public struct SectionedSurface<Content: View>: View {
    let content: Content

    public init(@ViewBuilder content: () -> Content) {
        self.content = content()
    }

    public var body: some View {
        ForEach(sections: content) { section in
            if !section.content.isEmpty {
                section.header.padding(.top) // 给 section 的 header 增加顶部间距
                section.content
                section.footer
            }
        }
    }
}

它使用了 ForEach(sections:),这个能力可以从传入的视图中提取所有的 Section,然后做统一处理。

林屿这里做了两件事:

  • 过滤掉没有内容的 section
  • 给 section header 增加一些顶部间距

这看着朴素,实际上很实用。

在这里插入图片描述

因为在真实业务里,section 常常是动态的。
某块有数据,就该显示;没数据,就该消失。
如果每个页面都自己处理一遍这些逻辑,迟早会写成一锅粥。

SectionedSurface 把这类规则直接吸收到了容器层。
页面只负责描述内容,容器负责决定组织方式。

这就叫分寸。
代码里有分寸,界面就不会失态。


➡️ 离开 List 后,NavigationLink 的箭头去哪了?

很多人一旦不用 List,很快就会发现少了点什么。
没错,就是 NavigationLink 右侧那个熟悉的小箭头,也就是 chevron

List 中,它会自动出现在 trailing edge,系统帮你安排得明明白白。可一旦离开 List,这个默认样式就没了。

在这里插入图片描述

林屿的办法很干脆:写一个自定义 ButtonStyle

public struct NavigationButtonStyle: ButtonStyle {
    public func makeBody(configuration: Configuration) -> some View {
        HStack {
            configuration.label
                .opacity(configuration.isPressed ? 0.7 : 1) // 按下时微微变淡,增加反馈感
            Spacer()
            Image(systemName: "chevron.right")
                .foregroundStyle(.tertiary) // 补回 List 风格的右侧箭头
        }
        .contentShape(.rect) // 扩大点击区域,让整行都可点
    }
}

extension ButtonStyle where Self == NavigationButtonStyle {
    public static var navigation: Self { .init() }
}

这一招的好处在于,它不是临时补救,而是顺势把“导航型按钮”的风格单独抽了出来。

在这里插入图片描述

以后只要写:

.buttonStyle(.navigation)

整页涉及导航的按钮,就能统一表现。

这才像回事。
高手不是把洞补上,高手是顺手把墙也砌直。


🏗️ 实战拼装:SummaryView

下面这段代码,展示了前面这些新原语在 app 中的实际用法。

public struct SummaryView: View {
    let summary: SummaryStore
    
    public var body: some View {
        ScrollingSurface {
            SectionedSurface {
                coachSection
                activitySection
                recoverySection
                vitalsSection
                heartRateSection
                alcoholicBeveragesSection
            }
        }
        .buttonStyle(.navigation) // 统一套用导航按钮样式
    }
    
    @ViewBuilder private var activitySection: some View {
        Section {
            if !summary.metrics.workouts.isEmpty {
                DividedCard {
                    ForEach(summary.metrics.workouts, id: \.workout.uuid) { snapshot in
                        NavigationLink {
                            WorkoutDetailsView(snapshot: snapshot)
                        } label: {
                            WorkoutView(snapshot: snapshot)
                        }
                    }
                }
            }
        } header: {
            SectionHeader(
                .horizontal,
                title: Text("activitySection"),
                systemImage: "figure.run"
            )
            .tint(.orange)
        }
    }
}

这一段真正漂亮的地方在哪?

表面上看,它的使用方式和 List API 非常像:

  • Section
  • NavigationLink
  • 有 header
  • 有内容分组

但底层已经换了天地。

在这里插入图片描述

林屿通过:

  • ScrollingSurface
  • DividedCard
  • SectionedSurface
  • NavigationButtonStyle

重新拼出了类似 List 的使用体验,同时拿回了对 look and feel 的精准控制。

更妙的是,如果某个页面压根不需要 sections,只要把 SectionedSurface 去掉即可,其余 primitive 仍然能继续复用。

这就说明它们不是页面特供,而是真正的可复用 building blocks

在这里插入图片描述

到了这一步,已经不是“替代 List”那么简单了。
这是在搭自己的界面语言。


真相大白:弃用 List 非叛逆,懂了取舍是清醒

最后,林屿把话说得很准。

SwiftUI 里替换 List,并不是要放弃一个强大的组件。它真正要表达的是:

不是背叛 List,而是为场景选择正确的工具。

如果你面对的是大型、统一的数据集,List 依旧是极好的选择,毫无问题。

在这里插入图片描述

但当你的 UI 需要更细致的结构、更独特的样式、更符合产品自身 design language 的表达时,现代 SwiftUI 已经给了我们足够的自由。

借助 ScrollView、lazy stacks 和 Container View APIs,我们不只是可以重建 List 的能力,某些时候甚至能够超越它。

ScrollingSurfaceDividedCardSectionedSurface 这样的自定义 primitive,证明了一件事:

真正成熟的 SwiftUI 代码,不只是把视图摆出来,而是把可复用的规则提炼出来。

性能、清晰度、设计语言,三者并行不悖。
这才是正路。

在这里插入图片描述


🌒 尾声:他最终没有推翻 List,只是看透了它

天快亮的时候,林屿合上电脑,办公室的灯光仍旧冷,心里却亮了。

他没有把 List 当成敌人。
也没有为了“自定义”而自定义。

在这里插入图片描述

他只是终于明白:
组件从来不是信仰,它只是工具。

该用 List 的时候,别拧巴;
该用 ScrollView 和自定义容器的时候,也别手软。

很多人写 UI,写到最后,写成了对系统组件的依赖。
可真正厉害的人,写到最后,会慢慢长出自己的容器、自己的规则、自己的语言。

在这里插入图片描述

那一刻,所谓 List replacement,其实已经不重要了。
重要的是,他终于从“会用组件”,走到了“会造秩序”。

而这,才是这篇文章最狠的一刀。

uni-app scroll-view 滚动卡死?一行CSS直接复活(iOS必看)

作者 于小洋
2026年4月10日 11:34

uni-app scroll-view 滚动卡死?一行CSS直接复活(iOS必看)

做uni-app开发的同学,有没有遇到过这种崩溃场景:页面用了scroll-view做滚动容器,点击Tab切换锚点后,整个页面突然不能滑动了,刷新也没用,只有重新进入页面才能恢复?

我最近就踩了这个坑,花了大半天排查,最后发现居然只要一行CSS就能解决,今天把整个排查过程和原理分享出来,帮大家避坑,尤其是做iOS端开发的同学,建议直接收藏备用!

一、问题复现(和我遇到的一模一样)

先给大家还原下我遇到的场景,方便大家对号入座:

  • 页面结构:用 scroll-view 包裹整个页面内容,内部分3个模块(基本信息、买车意向、卖车意向),顶部有Tab切换,点击Tab通过 scroll-into-view 实现锚点定位。

  • 问题现象:进入页面后,点击「卖车意向」Tab,锚点直接定位到模块最底部;此时尝试上下滑动页面,发现整个页面完全卡死,不能向上滑,只能向下滑(甚至向下滑也不流畅),刷新页面也无法恢复。

  • 环境:iOS端(真机+模拟器+Safari浏览器都复现),Android端正常,小程序端正常。

一开始我以为是锚点定位逻辑写错了,反复检查scroll-into-view、锚点高度计算,改了半天还是卡死,直到加上一行CSS,瞬间复活!

二、排查过程(踩坑记录,帮你省时间)

排查过程中,我走了3个弯路,大家可以跳过这些无效操作,直接看解决方案:

弯路1:怀疑锚点高度计算错误

一开始觉得是锚点高度获取有误——页面有「展示完整信息」的折叠/展开功能,初始化时获取的锚点高度是折叠状态的,展开后高度变化,导致定位偏移,进而触发滚动异常。

于是封装了锚点高度重新计算方法,在折叠/展开、Tab切换后重新查询DOM高度,虽然解决了锚点定位到底部的问题,但滚动卡死依然存在

弯路2:怀疑scroll-view滚动逻辑错误

接着检查scroll-view的滚动监听方法(scrollChange),发现里面有复杂的高度判断逻辑,比如用anchor2TopCopy动态计算偏移量,以为是判断条件出错导致滚动锁死。

简化了滚动监听逻辑,改成简单的三段式判断(根据滚动距离切换Tab),锚点定位更精准了,但滚动卡死问题还是没解决

弯路3:怀疑scroll-view样式配置错误

检查scroll-view的样式,确认已经设置了scroll-y="true"、flex:1、height:100%,没有多余的overflow样式冲突,排除了样式配置问题。

关键突破:定位到iOS原生回弹冲突

因为只有iOS端有问题,Android端正常,猜测是iOS原生特性和uni-app scroll-view的冲突。想起iOS有个「橡皮筋回弹」效果(overscroll),当scroll-view滚动到边界时,继续拉拽会出现空白回弹,会不会是这个回弹导致滚动状态错乱?

抱着试试看的心态,加了一行禁止回弹的CSS,没想到——滚动瞬间恢复正常,卡死问题彻底解决!

三、解决方案(一行CSS搞定,直接复制)

就是这行CSS,直接复制到你的页面样式中,iOS端滚动卡死问题瞬间解决:

::v-deep .uni-scroll-view, 
::v-deep .uni-scroll-view-content {
  /* 禁止iOS橡皮筋回弹,解决scroll-view滚动卡死 */
  overscroll-behavior: none;
}

补充说明:

  • ::v-deep 必须加:因为uni-app的scroll-view是组件封装的,需要穿透样式到子组件。

  • 适配范围:同时作用于.uni-scroll-view和.uni-scroll-view-content,确保所有滚动容器都禁止回弹。

  • 不影响其他功能:这行代码只禁止“边界回弹”,不影响正常滚动、锚点定位,Android端不受影响(overscroll-behavior在Android上兼容性有限,不会生效,也不需要生效)。

完美搭配(解决锚点+滚动双重问题)

如果你的页面也有折叠/展开模块,建议搭配锚点高度重新计算方法,实现“锚点精准+滚动流畅”:

// 重新计算所有锚点高度(折叠/展开后调用)
updateAnchorTop() {
  const query = uni.createSelectorQuery().in(this);
  query
    .select('#anchor1') // 基本信息锚点
    .select('#anchor2') // 买车意向锚点
    .select('#anchor3') // 卖车意向锚点
    .boundingClientRect((res) => {
      if (res[0]) this.anchor1Top = res[0].top;
      if (res[1]) this.anchor2Top = res[1].top;
      if (res[2]) this.anchor3Top = res[2].top;
    })
    .exec();
},

// 折叠/展开按钮点击事件
arrowClick() {
  this.arrow = !this.arrow;
  // 等待DOM渲染完成后重新计算锚点高度
  this.$nextTick(() => {
    this.updateAnchorTop();
  });
}

四、问题原理(为什么这行CSS能解决?)

核心原因:iOS的橡皮筋回弹(overscroll)与uni-app的scroll-view锚点定位冲突,导致滚动状态锁死

  1. 当点击Tab触发scroll-into-view锚点定位时,若定位到模块底部,会触发iOS的“越界回弹”(overscroll)。

  2. uni-app的scroll-view组件底层对滚动状态的处理,与iOS原生回弹机制不兼容,回弹后会导致scroll-view的滚动事件被阻塞,出现“卡死”。

  3. overscroll-behavior: none 的作用就是禁止元素的越界回弹行为,从根源上避免了冲突,滚动状态自然恢复正常。

补充:这不是你的代码写错了,而是uni-app在iOS端的一个经典兼容性bug,很多开发者都遇到过,一行CSS就能规避。

五、常见补充场景(避坑延伸)

如果加上这行CSS后,滚动还是有问题,大概率是以下2个原因,对应解决即可:

场景1:scroll-view高度计算错误

确保scroll-view的父容器有明确高度,scroll-view本身设置:

scroll-view {
  flex: 1;
  height: 100%;
  overflow-y: auto; /* 兜底,避免滚动异常 */
}

场景2:Tab切换锚点定位不精准

在Tab点击事件中,等待DOM渲染完成后再赋值锚点,避免异步高度问题:

tabClick(e) {
  this.indexNum = e;
  this.$nextTick(() => {
    this.anchor = e === 0 ? 'anchor1' : e === 1 ? 'anchor2' : 'anchor3';
    // 定位后兜底校准高度
    setTimeout(() => this.updateAnchorTop(), 100);
  });
}

六、总结

如果你在uni-app开发中,遇到iOS端scroll-view滚动卡死、不能滑动的问题,尤其是结合锚点定位、Tab切换时,直接用这行CSS就能解决:

::v-deep .uni-scroll-view, 
::v-deep .uni-scroll-view-content {
  overscroll-behavior: none;
}

本质是规避iOS原生回弹与uni-app scroll-view的兼容性冲突,属于“一招制敌”的解决方案。

另外,结合锚点高度重新计算方法,能同时解决“锚点定位偏移”和“滚动卡死”两个问题,适配更多复杂页面场景。

希望这篇文章能帮你节省排查时间,避免踩坑~ 如果还有其他uni-app滚动相关的问题,欢迎在评论区交流!

最后,求个点赞收藏,你的支持是我分享的动力 😊

赛博探案集:用 Vision 框架在像素迷宫中“揪”出文字真凶

2026年4月7日 09:59

在这里插入图片描述

这里是后厂村阴影中最神秘的“全栈侦探事务所”。当你的 if-else 走到尽头,当你的 Bug 堆积如山,资深探长“老司机”就是你最后的救命稻草。本期案卷记录了一次关于“像素与文字”的离奇遭遇:实习生阿强因“人肉 OCR”识别截图密码失败,险些引发上线事故。面对这起“视力危机”,我们拒绝蛮力,祭出了 Apple 强大的 Vision 框架。这不仅是一篇关于如何用 Swift 实现 OCR(文字识别)的硬核教程,更是一场从构建“文字捕手”到破解“坐标迷宫”的技术探险。准备好了吗?泡好你的枸杞咖啡,跟随老司机的代码,一起揭开隐藏在图片像素背后的真相。

🕵️‍♂️ 引子

在一个雷雨交加的周五深夜,位于后厂村的“全栈侦探事务所”依然灯火通明。传说中,这里有一位代号为“老司机”的资深工程师,他不仅能用汇编语言写情书,还能在没有任何文档的遗留代码(Legacy Code)中自由穿梭。

就在刚刚,事务所的大门被撞开了。实习生阿强跌跌撞撞地跑进来,手里挥舞着一张模糊不清的截图,脸上写满了被产品经理折磨后的绝望。“老大!出大事了!这图片里藏着服务器的 Root 密码,但我手抄了三次都提示错误!现在上线倒计时只剩 30 分钟了!”

在这里插入图片描述

老司机缓缓放下手中早已凉透的黑咖啡,推了推鼻梁上那副防蓝光眼镜,嘴角勾起一抹神秘的微笑。“阿强,把你的‘人肉 OCR’停一停吧。在 Apple 的地盘上,我们有更优雅的武器——Vision 框架。”

在本次探案之旅中,您将学到如下内容:

  • 🕵️‍♂️ 引子
  • 🤖 第一章:不仅是扫码工具人的 Vision
  • 🛠️ 第二章:打造“文字捕手” (The Text Recognizer)
  • ⚠️ 老司机的技术批注:
  • 🎯 第三章:给真相画个圈 (Highlighting Found Text)
  • 🤝 终章:真相大白

他指尖在机械键盘上飞舞,屏幕上开始跳动起绿色的代码符文。“坐好,今晚带你见识一下,如何用机器学习的‘天眼’,让图片里的文字自己‘招供’。”

在这里插入图片描述


🤖 第一章:不仅是扫码工具人的 Vision

听好了,阿强。大多数人对 Apple Vision 框架的印象,还停留在扫个二维码或者条形码这种“小儿科”的阶段。这就好比你拿着一把激光剑去切西瓜——简直是暴殄天物!

实际上,Vision 就像是给你的 App 装上了一双“写轮眼”。它不仅能从图片中识别并定位文字(Text Detection),还能把图片里的特定区域剥离出来、在连续的视频帧里追踪物体、甚至检测你那僵硬的手势和坐姿!

在这里插入图片描述

我第一次跟 Vision 打交道的时候,是写了一个 Swift 命令行工具来移除图片背景 ✂️。那时候我就意识到,这玩意儿简直是修图师的噩梦,程序员的福音。但今天,我们要用它来做点更硬核的——文字识别

在这里插入图片描述


🛠️ 第二章:打造“文字捕手” (The Text Recognizer)

要在茫茫像素中提取文字,我们得先组装一个名为 TextRecognizer 的“审讯室”。在这个环节,我们要用到 Vision 的核心组件:RecognizeTextRequest

这就好比我们向系统提交一份“搜查令”,告诉它:“嘿,帮我把这张图里的字儿都给我找出来,而且要准(Accurate)!”

在这里插入图片描述

来看看这段代码,这可是我们的核心武器:

import Foundation
import SwiftUI
import Vision
 
struct TextRecognizer {
    var recognizedText = ""
    // 保存识别到的所有“线索”(观察结果)
    var observations: [RecognizedTextObservation] = []
 
    // 这个初始化器是异步的,因为查案需要时间,急不得
    init(imageResource: ImageResource) async {
        // 1. 创建搜查令:RecognizeTextRequest
        var request = RecognizeTextRequest()
        // 2. 将识别精度设置为 .accurate(我们要的是精准打击,不是瞎猜)
        request.recognitionLevel = .accurate
        
        // 3. 将 ImageResource 转换为 UIImage
        let image = UIImage(resource: imageResource)
        
        // 4. 重点来了!Vision 不吃 UIImage 这一套,它只认二进制数据 Data
        // 所以我们必须把图片“粉碎”成 PNG 数据
        if let imageData = image.pngData(),
           // 执行搜查任务(perform)。这一步可能会失败,所以用了 try? 来“掩耳盗铃”
           // 注意:这里是异步等待结果
           let results = try? await request.perform(on: imageData) {
            
            // 5. 将抓获的嫌疑人(观察结果)关进 observations 数组
            observations = results
        }
 
        // 6. 审讯环节:遍历每一个观察结果
        for observation in observations {
            // 获取可能性最高的那个“候选词”(topCandidates(1))
            // 就像指认现场,我们通常只信最像的那个
            let candidate = observation.topCandidates(1)
            if let observedText = candidate.first?.string {
                // 把招供的文字拼接到结果字符串里
                recognizedText += "\n\(observedText) "
            }
        }
    }
}

在这里插入图片描述

⚠️ 老司机的技术批注:

这里有个坑你要注意,阿强。RecognizeTextRequest 是个挑剔的家伙,它不能直接处理 Swift 的 ImageUIImage 对象,它需要生肉——也就是 Image Data

在这里插入图片描述

所以我们必须先把图片转成 Data 格式。另外,整个过程是 async(异步)的,毕竟机器学习这玩意儿虽然快,但也没快到能超越光速,我们得给 CPU 一点“思考”的时间。

在这里插入图片描述

接下来,我们把这个“文字捕手”集成到 SwiftUI 的视图里,让你亲眼看看效果:

import SwiftUI
 
struct TextRecognitionView: View {
    let imageResource: ImageResource
    // 状态变量,一旦侦探有了结果,界面就会刷新
    @State private var textRecognizer: TextRecognizer?
 
    var body: some View {
        List {
            // 展示嫌疑图片
            Section {
                Image(imageResource)
                    .resizable()
                    .aspectRatio(contentMode: .fill)
                    .clipShape(RoundedRectangle(cornerRadius: 8))
            }
            .listRowBackground(Color.clear)
 
            // 展示审讯结果(识别出的文字)
            Section {
                // 如果 textRecognizer 还没初始化好,就先显示空字符串
                Text(textRecognizer?.recognizedText ?? "")
            } header: {
                Text("从图片中提取的证词")
            }
        }
        .navigationTitle("文字侦探")
        .task {
            // 重点:在 .task 修饰符里调用异步初始化器
            // 就像在后台偷偷干活,不阻塞主线程 UI 的渲染
            textRecognizer = await TextRecognizer(imageResource: imageResource)
        }
    }
}

这时候,阿强凑过来看着模拟器屏幕,只见原本模糊的截图下方,整整齐齐地列出了识别出来的文字。“卧槽,神了!连那个像‘1’又像‘l’的字符都分清了!”

在这里插入图片描述


🎯 第三章:给真相画个圈 (Highlighting Found Text)

“别急着庆祝,阿强。”我敲了敲桌子,“光把字认出来还不够,我们要做到按图索骥。既然 Vision 已经告诉了我们文字在哪里,我们就得在图片上把它们圈出来,就像犯罪现场的粉笔线一样。”

在这里插入图片描述

这里涉及到一个让很多新手头秃的概念:坐标系转换

Vision 返回的坐标是归一化的(Normalized),也就是说,它的 x 和 y 都在 0.0 到 1.0 之间。左下角是 (0,0),右上角是 (1,1)。但我们的屏幕图片是按像素画的,而且 UIKit/SwiftUI 的坐标原点通常在左上角。这就好比火星人给地球人指路,如果不好好翻译一下坐标,你画的框可能会飞到姥姥家去。

我们需要定义一个 Shape,专门用来画框:

import Foundation
import SwiftUI
import Vision
 
struct BoundsRect: Shape {
    // 这里存的是 Vision 给我们的“火星坐标”(归一化矩形)
    let normalizedRect: NormalizedRect
 
    func path(in rect: CGRect) -> Path {
        // 关键时刻!将归一化坐标转换为图片的实际像素坐标
        // origin: .upperLeft 是为了适配 SwiftUI 的坐标系习惯
        let imageCoordinatesRect = normalizedRect
            .toImageCoordinates(rect.size, origin: .upperLeft)
        return Path(imageCoordinatesRect)
    }
}

在这里插入图片描述

🔍 技术扩展: toImageCoordinates 这个方法虽然原文没细说,但它大概率是一个扩展方法(Extension),用于把 0~1 的小数映射到图片的 widthheight 上,并处理坐标原点的翻转。这一步至关重要,不做这一步,你的框框就会像没头苍蝇一样乱撞。

在这里插入图片描述


在这里插入图片描述

现在,我们把这个“现形符”贴到图片上:

struct TextRecognitionView: View {
    // ... 前面的代码 ...
    
    // 定义一个深红色的框,充满了悬疑感
    let boundingColor = Color(red: 0.31, green: 0.11, blue: 0.11)
 
    var body: some View {
        List {
            Section {
                Image(imageResource)
                    .resizable()
                    .aspectRatio(contentMode: .fill)
                    .clipShape(RoundedRectangle(cornerRadius: 8))
                    .overlay {
                        // 如果侦探已经有了观察结果
                        if let observations = textRecognizer?.observations {
                            ForEach(observations, id: \.uuid) { observation in
                                // 遍历每一个观察点,画个圈圈诅咒...啊不,标记它
                                // observation.boundingBox 就是那个归一化的坐标
                                BoundsRect(normalizedRect: observation.boundingBox)
                                    .stroke(boundingColor, lineWidth: 3) // 描边
                            }
                        }
                    }
            }
            // ... 后面的代码 ...
        }
    }
}

在这里插入图片描述

随着代码重新编译运行,屏幕上的截图发生了变化。每一个单词周围都被套上了一个暗红色的方框,就像是被狙击手锁定的目标。

在这里插入图片描述


在这里插入图片描述

🤝 终章:真相大白

“看到了吗?”我指着屏幕上被红框圈出的一串字符,“那根本不是 Root 密码。”

阿强瞪大了眼睛,盯着那行被 Vision 精准识别出的文字:WIFI_PASSWORD: 12345678

“这……这就是隔壁会议室的 WiFi 密码?”阿强瘫软在椅子上,“我为了这个通宵了两天?”

在这里插入图片描述

我拍了拍他的肩膀,语重心长地说道:“虽然你是个笨蛋,但好在 Vision 框架足够聪明。记住,Vision 不仅仅能找字,它还能做更多事情——从视频里追踪隔壁老王的身影,到检测你是不是在偷偷抠脚(Body Pose Detection)。今天我们学的只是冰山一角,但也足够你在这个充满像素迷雾的开发世界里防身了。”

就这样,Vision 框架再次拯救了一个无知的灵魂(虽然并没有拯救他的加班费)。

在这里插入图片描述

希望宝子们喜欢这个故事,以及它背后的技术,但对于小伙伴们来说,利用 Apple 强大的 ML 能力去探索未知的旅程,才刚刚开始。

在这里插入图片描述

保持好奇,保持代码整洁,我们下个案子见。👋🙂 8-)

穿透内容审查与阻断:基于 DNS TXT 记录的动态服务发现与客户端安全加固实践

作者 eleven4096
2026年3月20日 13:55

✍️ 引言

在开发面向全球或特定复杂网络环境的 App(如 XXX、跨境电商、海外加速等)时,最大的痛点往往不是业务逻辑,而是服务端的生存能力。为了对抗域名污染 (DNS Poisoning)SNI 阻断 以及 证书审查,我们通常需要一套极其灵活的「备用链路」与「动态发现」机制。

本文将结合在 iOS/Swift 项目中的实际落地经验,深度剖析一套基于 DNS TXT 记录 派发动态入口域名双向 mTLS 证书(p12)基码 以及 原生 TCP 直连 IP 的高可用架构,并详解其间的技术难点与避坑指南。


🛠 一、 核心架构设计

我们的目标是:哪怕主 Base 域名完全死锁,客户端只要能向公用 DNS 发一个查询,就能满血复活。

1. 数据如何藏在 DNS TXT 里?

由于一台域名的 A记录 只能存 IP,且极其容易被封锁,我们选择将配置加密后塞入 DNS 的 TXT 记录。 我们使用了多级子域名来承载不同的模块(由于 TXT 字符长度限制,需要分片):

子域名 (Subdomain) 承载内容特征 安全措施
root.yourbase.com 加密后的后备 HTTPS 业务 API 域名列表 AES-128-ECB 加密 + Base64
1.yourbase.com mTLS 客户端证书 P12 文件的 Base64 前半段 纯文本分片拼装
2.yourbase.com mTLS 客户端证书 P12 文件的 Base64 后半段 纯文本分片拼装
ip.yourbase.com 绕过 SNI 审查的裸 TCP 直连 IP 点对点通道 纯文本

🧠 二、 技术难点与避坑指南

难点 1:iOS 系统 API 无法直接发起原生 UDP DNS 查询

🚨 问题背景:  iOS 的 getaddrinfo 或者 NWHostResolver 是高层级 API,它们往往只返回处理好的 IP 地址(A/AAAA 记录),极难直接读取到 TXT、SRV 记录。如果调用系统的 res_nquery(属于 C 层的 libresolv),在弱网下容易造成线程死锁,且容易触发 iOS 严格的后台审计。

💡 解决方案:使用 Network 框架手工构建 UDP 53 端口查询 我们在 Swift 中封装了一个 DNSResolver,通过 NWConnection(to: 53, using: .udp) 手工下发标准 DNS 报文(RFC 1035)

  1. 构造 DNS 查询帧

    swift
    var data = Data()
    let id = UInt16.random(in: 1...65535)
    data.append(contentsOf: id.bigEndianBytes)
    data.append(contentsOf: UInt16(0x0100).bigEndianBytes) // Flags: 标准查询
    data.append(contentsOf: UInt16(1).bigEndianBytes)      // Question 数量 1
    // ... 拼接子域名 QNAME、QTYPE 为 16 (TXT)
    
  2. 并发查询优化: 由于国内 DNS 偶尔会有运营商后门或缓存污染,我们使用 withTaskGroup 并发地向四个公共 DNS 服务器发送请求 (223.5.5.5114.114.114.1148.8.8.81.1.1.1),谁最快返回合法的 TXT 内容,就直接 cancelAll() 结束任务


难点 2:UDP 的截断陷阱 (Truncated) 与 TCP 回退

🚨 问题背景:  由于拼装了庞大的客户端 p12 证书 Base64 字符串,TXT 记录往往会合在一起超过 512 字节。 在标准的 DNS UDP 查询中,如果响应超过 512 字节,包头部的 TC (Truncated) 标志位会被置为 1,代表数据被截断。

💡 解决方案:标志位侦测与 TCP Fallback 我们在 UDP 接收处做了一层守卫:

swift
if (data[2] & 0x02) != 0 {  // TC Flag is set!
    // UDP 遭遇截断,降级使用 TCP 53 端口进行可靠全量查询
    return await queryTCP(domain: domain, server: server)
}

进入 queryTCP 时,会在帧最前面补上 2 字节的大端序长度头,直接利用 NWConnection.tcp 握手拿到绝对完整的几千字节 TXT 加密串,完美解决大文件丢失问题。


难点 3:防劫持的 “端到端解密” 校验

🚨 问题背景:  如果中间人(Mitm)故意把你的 TXT 记录篡改成钓鱼网站或错误信息,即便配置下发了,APP 也会崩溃或中招。

💡 解决方案:AES + TCP 握手活性测试

  1. 对称加密:对 root 的分流域名进行 AES-128-ECB 加密。中间人即使拿到了,没有客户端的硬编码 Key 也无法篡改。

  2. TCP 通信握手探测活性: 在真正切换配置前,Manager 会多跑一遍 tcpTest。由于有些域名可能已经“挂了”,客户端会在后台静默并发跑:

    swift
    let connection = NWConnection(to: host, using: .tcp)
    connection.stateUpdateHandler = { state in
        if state == .ready { finish(true) } // 代表服务器可通达,不是死域名
    }
    

难点 4:动态 mTLS 证书灌入 (Security Manager)

经过 AES 解密和两片 TXT (1.txt + 2.txt) 拼装后,我们得到了完整的证书 Base64 编码。 我们要实实现本地无感知实例化,不需要把证书文件落地写死到沙盒里(防止反编译静态检查):

  1. 直接在内存中将组合好的 Base64 数据转为 Data
  2. 使用 SecPKCS12Import 函数,并将空密码(或者约定的暗号)传入,从内存里动态吐出 SecIdentity 和关联的 SecCertificate
  3. 把 Identity 灌入全局 SessionDelegate。当走 HTTPS 握手时,若触发 .clientCertificate 的 URLAuthenticationChallenge,直接从 cache 提取该 Identity 给系统使用。

难点 5:SNI 阻断应急方案 —— 18字节头部纯裸 TCP 定制通道

对于国内在极限阻断(如 SNI 嗅探)下的特殊业务,HTTPS 甚至会被阻断。我们追加了 ip.yourbase.com 提取裸 IP:

  • 业务无感降级:当 HTTPS 全灭,NetworkChannelManager 自动引导流量降级到我们自己用原生 NWConnection 敲出来的裸 TCP 直连。
  • 自定义封包协议:由于对端没有 TLS 证书做阻断,我们在应用层通过自研非对称二进制报头([18字节头部][Path][Hdr][Body] 及 响应 14字节头部)在服务端和客户端穿梭自如,极大增强了业务的可达率骨干。

📈 三、 业务安全成效

通过这套机制的上线,我们成功做到了:

  1. 云端无感知脱壳切换:后台可随意增减高防域名、甚至随时全量更替 TLS 的客户端校验私钥,对老版本客户端保持完美兼容。
  2. 零阻断时长:冷启动到成功跑通业务 HTTPS 的时间通过 TaskGroup 的竞赛机制下降到了 平均 0.3 秒以内。

💡 总结

服务高防链路的最佳伴侣不是冗余服务器,而是灵活、弹性的 发现机制。 利用 DNS 53 这个处于网络信任基座的协议,将 分片加密数据 优雅地回传至 iOS 客户端并发解码,不仅安全可靠,更筑起了一道无法轻易折断的强硬长廊。


提示:  在使用 114 / 223 等大陆 DNS 查询时,注意频率控制以心跳避免被运营商拉入恶意解析黑名单。对于更深层的防污染,甚至可搭配 DNS over HTTPS (DoH) 来取代 53 端口查询。

NativeScript iOS 平台开发技巧

作者 sp42_frank
2026年3月20日 09:35

升级到 NativeScript 8.7 后出现 APPLE is not defined 错误

出现了__APPLE__ is not defined 错误,是在你将 @nativescript/core, @nativescript/ios, @nativescript/android 升级到 ^8.7.0 版本后可能遇到的一个烦人错误。

官方推荐所有人都升级到 NativeScript 8.7,因为它包含了许多错误修复和改进,例如 devtool 以及恢复了从 8.4 版本开始中断的网络检查功能。然而有些人可能会遇到像下面这样的奇怪错误:

System.err: ReferenceError: __APPLE__ is not defined
System.err:
System.err: StackTrace:
System.err: ./node_modules/@nativescript/core/accessibility/font-scale-common.js(file: src/webpack:/FarmOps/node_modules/@nativescript/core/accessibility/font-scale-common.js:1:7)

原因

__APPLE__ is not defined 错误是由于 NativeScript 在他们的构建代码中引入了一些新的占位符。这些占位符依赖 Webpack 在构建时进行替换。而这个逻辑是在 @nativescript/webpack 5.0.19 中引入的。所以关键是确保你使用的 @nativescript/webpack 至少是 5.0.19 版本,才能成功使用 NativeScript 8.7 构建你的项目。

解决方案

所以基本上,解决 __APPLE__ is not defined 错误的方法是确保两件事:

  1. 首先,确保 @nativescript/webpack 的版本在你的 package.json 中没有被限制,像这样是最好的:^5.0.0
  2. 其次,确保你的 npm 已经知晓了 @nativescript/webpack 的最新可用版本,并且没有任何缓存。对我而言,我会执行 rm -rf node_modulesrm package-lock.json 然后再重新运行 npm i 来确保。或者更简单地,执行 ns clean 然后重新运行。

你总是可以尝试查看 package-lock.json找到 @nativescript/webpack 部分。如果它看起来像这样: 在这里插入图片描述

这表明实际安装的版本是 5.0.18,这是不行的。需要用我上面提到的任一种方法来解决。

在确保 @nativescript/webpack 版本没问题后,你现在可以再次运行 ns run 来继续你的 NativeScript 开发工作。

附言:如果你正在经历常见的 NativeScript 问题,并且需要一些快速修复或解决方法,请务必查看我们的“快速修复”部分。在这一部分,你会发现我在 NativeScript 之旅中收集的技巧和窍门,以及解决常见问题的解决方法。希望能帮助到许多像我一样的人。

NativeScript iOS: 无法启动模拟器

作为一名 iOS 开发者,最令人沮丧的事情莫过于 iOS 模拟器突然停止工作。这个工具对于在受控环境中测试和调试你的应用程序至关重要。当它失效时,你的工作流就会戛然而止,打乱工作效率并造成不必要的压力。

问题:无法启动模拟器

模拟器就是不工作,拒绝启动。并且一直说“无法启动模拟器”。 在这里插入图片描述

解决方案:

这个修复方法非常简单。

对于 Mac Ventura 13.0 及更高版本的操作系统 -> 点击 Mac 左上角的苹果图标 > 系统设置 > 搜索存储空间 > 等待加载,然后点击开发者 (Developer)。

在这里插入图片描述

在下一个屏幕中,选择删除 Xcode 缓存 (deleting Xcode Caches)。

删除完成后。尝试重新启动你的模拟器,现在它应该又能正常工作了。

如何正确修复:Info.plist 键 'BGTaskSchedulerPermittedIdentifiers' 必须包含一个标识符列表在这里插入图片描述

对于一个 NativeScript 应用,这个错误通常出现在 iOS 应用开发的上下文中,具体来说,当你或你安装的某些插件试图使用后台任务时,就会出现这个问题。

解决方法

  1. 打开你的应用的 App_Resources/iOS/Info.plist 文件。
  2. 如果尚不存在,添加键 BGTaskSchedulerPermittedIdentifiers
  3. 将其类型设置为 Array(数组)。
  4. 对于每个后台任务,在此数组中添加一项。每一项都应该是一个字符串,代表一个后台任务的唯一标识符。

使用示例

<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>

如果你有任何自定义的后台任务,你也需要将其 ID 列入上面的数组中。除此之外,你可以直接使用上面这段代码。

$(PRODUCT_BUNDLE_IDENTIFIER) 将被解析为你在 nativescript.config.ts 中定义的应用 Bundle ID,例如:com.newbiescripter.myawesomeapp

请记住,在 iOS 中使用后台任务有一些限制和准则,因为苹果旨在优化电池续航和性能。请确保你使用后台任务的方式符合这些准则。

苹果谷歌纷纷调低官方抽成,苹果谷歌全球抽成比例汇总

作者 CocoaKier
2026年3月13日 18:06

一、苹果中国区抽成“紧急”下调

2026年3月12日,苹果突然宣布中国区AppStore官方抽成从 30% 改为 25%,小型开发者抽成从15% 改为 12%2026年3月15日生效来源

想必,今天大家都被这个截图刷屏了吧。

图片.png

为什么说“紧急”呢?
1、“根据与中国监管部门的沟通”,写得很清楚,是中国监管部门推动的;
2、“自3月15日起”,约等于立刻生效,对比谷歌的三个月后生效,凸显一个“急”;
3、“调整无需开发者在此之前签署新条款”,手续流程都免了,直接生效!
2、“更新版协议的简体中文版将于一个月内在 Apple开发者网站上线”,流程后面再补,先上线!

不知道苹果发生了什么,但是感觉很爽。有种苹果被工信部发了违规整改通知的感觉(DDDD),让苹果也尝尝工信部的厉害,马上整改,立刻上线!哈哈哈。

中国开发者什么都不用做,代码都不用改,就额外增(bai)加(piao) 3%~5% 的收益。

感谢那些为此做出贡献的人!

补充:有律师说出了苹果紧急“降税”的真相 ,有兴趣的可以点开看看。

二、谷歌将陆续降低全球抽成并开放三方支付

苹果紧急降低抽成除了迫于监管压力,估计也迫于竞争对手的压力。

早在3月4日,谷歌在安卓开发者网站发布了一篇博客《选择和开放的新时代》宣布将陆续在全球降低抽成,开放第三方支付,并且后续除了《小型开发者计划》外,还会新推出《应用体验计划》和《游戏升级计划》来让利开发者

《应用体验计划》《游戏升级计划》的本质:质量换费率。通过经济激励(降低费率)来引导开发者提升应用和游戏的整体品质。开发者必须达到相应的技术集成和体验标准,来满足计划条件,才能获得费率减免。举例说明,比如,游戏类必须集成 Play Games Services 功能(如成就系统、现代玩家个人资料认证)。Play Console 中的“Android Vitals”指标,确保应用在崩溃率、ANR(无响应)率等方面符合谷歌的健康度标准。

计划的具体内容,谷歌尚未公布。

谷歌将现有的抽成拆成了两部分:
Google商店服务费:标准20%、参加上述新计划15%、小型开发者10%、订阅10%(取最小值)
Google支付服务费:约5%(每个地区可能不一样)

在美国、英国和欧洲经济区 (EEA),支付服务费为 5%。其他地区的支付服务费详情谷歌后续公布。

商店服务费,只要你在谷歌商店上架就要交,不管你用谷歌支付还是三方支付;
支付服务费,用谷歌支付就要交,用三方支付不交。

谷歌最终抽成比例:
官方支付抽成:15%~25%
三方支付抽成:10%~20%

谷歌新政策全球上线后,官方支付和三方支付只差5%,三方支付还得加上3%左右的通道费,和官方支付相比,三方支付毫无竞争力,这也是为什么谷歌敢在全球开放三方支付的原因。

需要注意的是,这次费率变化并非即刻生效,而是将分时间、逐步在全球不同地区推广:

各区域的推出日期 地区 《应用体验计划》《游戏升级计划》上线地区
2026年6月30日 欧洲经济区、英国、美国  
2026年9月30日 澳大利亚 澳大利亚、欧洲经济区、英国、美国
2026年12月31日 日本、韩国 日本、韩国
2027年9月30日 世界其他地区 世界其他地区

三、苹果、谷歌全球抽成比例汇总

目前,谷歌和苹果,在全球都面临着反垄断、三方支付、三方商店的压力,革命一旦发起,就像星星之火一样会传递到全世界,一会这个国家闹,一会那个国家闹。面对这样的情况,谷歌和苹果却走出了不一样的应对路数。

1、谷歌全球统一标准

谷歌,将在2026年到2027年陆续在全球执行统一的新标准,开放三方支付、开放三方商店。

Google商店服务费:标准20%、参加上述新计划15%、小型开发者10%、订阅10%(取最小值)
Google支付服务费:约5%(每个地区可能不一样)

官方支付抽成:15%~25%
三方支付抽成:10%~20%

全球实行时间线:

各区域的推出日期 地区 《应用体验计划》《游戏升级计划》上线地区
2026年6月30日 欧洲经济区、英国、美国  
2026年9月30日 澳大利亚 澳大利亚、欧洲经济区、英国、美国
2026年12月31日 日本、韩国 日本、韩国
2027年9月30日 世界其他地区 世界其他地区

2、苹果按闹施政

从目前来看,苹果是按闹施政,谁闹我就便宜点,不闹就维持原样。但感觉不是长久之计,说不定苹果后续也会像谷歌那样统一标准。目前情况来看,谷歌还是眼光更长远一些,走在了前面,胸襟更大。

以下是苹果当前(2026.3.13)全球费率情况

地区 官方内购参考佣金 三方支付苹果抽成 备注
欧盟 13% - 20%,官方文档 15%~20% 欧盟计费很复杂,还会按安装量抽成
日本 15% - 26%,官方文档 10%~15%,外部链接购买 10%~21%,应第三方购买  
韩国 15% ~ 30% 11% ~ 26%  
美国 15% - 30% 0%,外部链接购买 海外公司可以申请;必须同时提供内购作为备选;仍然向苹果上报收入用于审计
中国 12% ~ 25%,官方文档 不允许三方支付  
其它 15% ~ 30% 不允许三方支付

和谷歌一样,苹果也把抽成拆了商店服务费+支付服务费,从上表可以看到三方支付和官方支付比也没有优势。

美国外链支付比较特殊,可以做到0%费率,但同样要满足三方支付的苛刻条件:必须接入官方内购作为备选、有苹果警告弹窗、仍然需要上报三方收入给苹果审计。

如果你对三方支付感兴趣可以看看我往期文档《三方支付真的香吗?日本iOS、Google三方支付调研报告 》,这篇虽然讲得是日本,但三方支付的接入流程和要求,全球都是一样的。

苹果谷歌商店:如何监控并维护用户评分评论

作者 CocoaKier
2026年2月28日 18:59

前阵子,我无意中发现我们的应用在 App Store 上悄然出现了几条差评,但团队里似乎没人注意到。这让我意识到一个严重的问题:如果我们不能及时听到用户的声音,怎么能及时发现应用的不足,留住用户呢? 更令人担忧的是,潜在用户在下载前往往会浏览评论区,一条未被回应的负面评价,可能就足以让他们转身离开,影响新增转化。

如果能在用户留下评论(尤其是差评)的第一时间收到通知,我们就能快速响应、修复问题、安抚情绪,甚至将一次不满转化为一次忠诚度的提升。更重要的是,积极、真诚地回复用户评论,不仅能展现团队的专业与负责,还能向所有观望者传递一个信号:我们在乎每一位用户。

本篇文章将从实操角度出发,为不熟悉苹果和谷歌开发者后台的开发或运营同学,讲解如何监控苹果谷歌商店的评分评论,以及如何回复用户评论,为大家提供一些帮助。

一、苹果

苹果开发者后台 appstoreconnect.apple.com/,需要 客户支持 权限。

1、如何监控评分和评论

苹果后台目前不支持收到新的评分评论后邮件通知开发者。只支持“开发者回复”(当顾客编辑你已回复的评论时,你将收到电子邮件),如需开启“开发者回复”邮件通知,按下面步骤操作:

登录 App Store Connect。
点击右上角的用户头像,进入 “用户和访问”。
选择你的账户,在左侧菜单点击 “通知”。

Tips:“收到评分评论后邮件通知开发者”,这个功能在旧版 iTunes Connect 中曾经存在,但在新版 App Store Connect 中已被移除。猜测苹果可能不想开发者过度关注单条评分评论。

如果目前想要监控苹果商店的评分评论,有几个方案可参考:
1、使用官方的 App Store Connect App,每天刷一刷,自己主动去看。App内可以设置“接收用户评分”通知,但不确定现在还是否有效。
2、苹果官方提供了App Store Connect API,可以自己开发程序拉取用户评分,再进一步做监控。
3、滴答清单定个周期性提醒,每天上班打开商店详情瞅一眼,现在苹果上线了Web版AppStore了,瞅一眼也很方便。
4、借助第三方平台。

2、查看和回复用户评论

(1)通过网页端查看

登录苹果开发者后台,appstoreconnect.apple.com/

评分评论入口:分发 - 评分和评论 图片.png

点击“回复”可以回复用户评论
图片.png

(2)通过官方App "App Store Connect" 查看

iOS端下载地址:apps.apple.com/cn/app/app-…
(如果你搜不到可能是你手机系统版本太低了。没有安卓端。)

图片.png App Store Connect App核心功能:
-- 销售与趋势监控(查看 App 的下载量、销售额)
-- 版本状态管理(跟踪审核状态,回复审核)
-- 用户评论处理(查看和回复评论)

App Store Connect内查看评分及评论入口:
图片.png

3、重置总评分

发布新版本到 App Store 时(必须更包),你可以重置 App 总评分。重置后,你的 App Store 产品页面将显示说明,提示顾客 App 的总评分最近已重置。此说明将一直显示,直到有足够多的顾客对新版本进行了评分且页面出现新的总评分。

请注意,重置总评分并不会重置顾客评论,App Store 仍将继续显示历史的顾客评论

图片.png

二、Google

Google开发者后台 play.google.com/console/dev…,需要 用户反馈 权限。
“用户反馈”权限

1、如何监控评分和评论

Google官方支持收到新的评论后邮件提醒开发者,并支持按应用、评分星级设置不同的提醒开关。注意:邮件提醒默认是关闭的,需要手动开启。请按下列步骤操作。

Google开发者后台 - 设置 - 个人邮件通知(这个只会改你个人的通知设置,不会改整个团队的) 图片.png

按需将邮件提醒开关打开,修改后记得保存。
图片.png

如果你的账号拥有开发者账号下多个App的权限,默认是所有应用都给你发邮件,点击下图位置,可以选择哪些应用接收邮件。 图片.png

收到新的评论后,Google会给你推邮件,模板样式如下,包含了应用名称、评分星级、评论内容,不用打开Google后台就能看到评论内容,很方便。
注意:如果你接收了多个应用的邮件,请留意邮件标题里App的名字。

图片.png

2、查看和回复用户评论

(1)网页端

Google后台 - 应用 - 监控与改进 - 评分与评价。

Google后台的评论,Google会默认帮你翻译成你的语言,很贴心。如果你想看原始评论,点击“显示原评论”查看。你也可以在这里回复用户的评论。
图片.png

(2)官方 Google Play Console App

Google也像苹果一样,提供了官方的供开发者维护自己App的应用,Google Play Console App。你可以通过它在移动端方便的看评分和回复评论。

iOS端:apps.apple.com/cn/app/goog…
安卓端:play.google.com/store/apps/…

Google Play Console App

Google Play Console App 核心功能:

  • 查看数据指标:监控安装量、卸载量、更新量以及应用的崩溃率(ANR/Crash)。
  • 回复用户评论:及时查看并回复用户的评价,这对于维护 App 评分至关重要。
  • 订单管理:查看应用内购买和订阅的订单详情,甚至可以进行简单的退款操作。
  • 发布状态监控:跟踪应用版本的审核进度和发布状态。

3、Google不支持重置评分评论

Google不像苹果那样可以主动重置评分。虽然你不能手动重置,但 Google Play 的评分系统是动态权重的,更加偏重于近期(Recent)的用户评分权重会更高

这意味着:
(1)如果你的应用过去因为有 Bug 而评分很低,只要你在新版本中修复了问题,随着新用户和老用户在近期的好评增多,你的平均分会逐渐回升。
(2)时间是最好的解药:只要新版本的体验确实提升了,评分曲线会自动向好的方向修正。

三、结束语

其实维护应用商店的评论,并不需要多么复杂的流程或高深的技巧,但你做了和没做,用户感受是不一样的,每个人都希望被尊重,用真诚打动你的用户吧!

希望这篇文章能给你一点帮助。如果你有更好的监控方法,欢迎留言交流。

参考文档
【苹果官方文档】查看评分和评论

百款出海社交 App 一夜下架!2026,匿名社交的生死劫怎么破?

作者 iOS研究院
2026年2月25日 20:15

2026年2月24日,出海社交领域迎来标志性的“黑色星期二”,百余款社交类App在无任何预警、无邮件通知、无申诉通道的情况下,被App Store集体下架。即便部分应用近期刚完成版本更新、运营状态平稳,也未能幸免。此次事件引发行业震动,苹果的清理行动究竟是偶然误伤还是定向整治?下架风暴的背后暗藏哪些监管逻辑?出海社交开发者如何突破困境、实现可持续发展?本文将深入拆解事件本质,梳理监管趋势,提供合规生存路径。

ScreenShot_2026-02-25_194516_417.png

ScreenShot_2026-02-25_194451_015.png

定向整治而非偶然误伤,四大市场同步发力

此次App Store下架行动并非随机操作,而是覆盖美国、澳大利亚、巴西、新加坡四大核心市场的定向清理,各市场虽审查重点略有差异,但整治核心高度统一,均聚焦于高风险社交场景。

美国作为全球最核心的应用市场,下架应用表面涵盖AI音乐、职场社交、旅游、育儿等多个品类,但核心筛选标准清晰——凡是包含“Live Chat”“Video Chat”“Meet New Friends”等关键词、以陌生人实时互动为核心功能的社交应用,均成为清理重点。

新加坡与澳大利亚的清理逻辑高度一致,对匿名社交类应用实施“零容忍”政策,大量主打“匿名聊天”“视频聊天”的产品被集中移除,其中不乏Aloha Live - Anonymous Chat、Xonder: Anonymous Chat & Vent等直接以“匿名”为核心卖点的应用,凸显两地对不可追溯社交模式的严格监管态度。

巴西市场的清理范围进一步扩大,除纯社交应用外,春辉乐玩、玩伴Vibe等具备旅游属性的轻度社交产品也被纳入下架名单。这一举措背后,是巴西市场将用户数据安全与未成年人保护纳入核心审查维度,审查标准提升至历史新高。

中国开发者高频踩雷:四类高危产品触发监管红线

梳理此次被下架的中国开发者相关产品,可发现其普遍存在明确的“高危特征”,均精准触碰了全球监管红线,具体可分为四大类:

1. 匿名树洞类产品

以默言、nimi-i人专属匿名聊天为代表,这类产品精准定位职场人、社恐群体的表达需求,主打“匿名对话”“无社交压力”等核心卖点,部分产品甚至取消点赞、推荐、动态广场等功能,极致强化匿名属性。但在监管层面,匿名意味着用户行为不可追溯,此类模式被明确界定为“高风险交互模式”,极易成为不良信息传播的载体,从而触发监管处罚。

2. 速配交友类产品

连连婚恋、LivMe-Meet new friend等产品均以“陌生人速配”为核心模式,前者面向职场人群提供免费婚恋交友服务,后者主打全球范围内的随机匹配聊天。此类产品的核心痛点的在于,多数中小开发团队难以承担7×24小时实时内容审核的成本,缺乏完善的审核机制,导致诈骗、色情等违法违规信息极易滋生,成为监管重点整治对象。

3. AI情感伴侣类产品

Joiy、ItsMee等产品将AI技术与情感社交深度结合,推出AI聊天、情绪匹配、专属AI聊天机器人等功能,看似是产品创新,实则触碰监管敏感点。AI技术本身并非违规核心,但当AI被用于模拟人类进行情感交流,且存在触达未成年人的可能时,监管容忍度降至零。此次下架也明确释放信号:情感类AI社交已成为全球监管的下一重点领域。

4. 马甲工具/社区类产品

部分产品以工具、垂直社区为外壳,暗藏社交属性,例如摄影社区CNU-顶尖视觉精选,虽以摄影内容分享为核心,但包含UGC内容发布、用户私信互动等社交功能,最终也被纳入清理范围。这一现象表明,只要涉及用户互动与内容传播,无论产品外在形态如何,均需遵守社交应用监管规范,不存在“法外之地”。

双重监管合围:苹果新规与全球法律形成监管合力

此次下架风暴的爆发,并非苹果单独行动,而是苹果平台规则升级与全球各国监管政策收紧形成的合力,推动出海社交行业正式进入“强合规时代”。

苹果平台规则升级:匿名社交被明确禁止

2026年2月6日,苹果悄然更新《App Store审核指南》,在1.2章节“用户生成内容”中,明确将“随机或匿名聊天”与色情内容、人身威胁、欺凌等列为App Store禁入类型,并保留“未经通知即可移除应用”的权利。

此前广泛应用于陌生人社交的Chatroulette式随机匹配模式,曾是行业核心创新点,如今已被定义为高风险功能。苹果的监管逻辑清晰:匿名+随机社交模式需要极致的内容审核能力,而多数中小开发团队难以承担相应成本,为规避平台风险,采取“一刀切”的清理策略。

全球各国监管收紧:未成年人保护成核心红线

如果说苹果新规是“平台层面的管控”,全球各国的法律政策则是“市场层面的约束”,且均以未成年人保护为核心,进一步压缩不合规产品的生存空间:

——巴西、澳大利亚、新加坡:自2月24日起,下载18+应用需通过苹果年龄验证;巴西额外规定,包含“开箱抽奖”等类赌博机制的应用,直接评级为18+,直接切断此类社交+游戏类产品的未成年人用户市场。

——美国:犹他州《应用商店责任法案》已于2025年5月生效,要求应用商店强制验证用户年龄,未成年人账号需关联家长账号,开发者违规将面临家长最高1000美元/次的索赔,苹果为规避“连坐”风险,进一步提高应用审核标准。

——欧洲:欧盟近期认定TikTok的“成瘾性设计”(如无限滚动、自动播放)违反《数字服务法案》,拟处以全球年收入6%的罚款;西班牙更推进“禁止16岁以下未成年人使用社交媒体”的政策,进一步强化对未成年人的保护。

综上,此次下架风暴是全球监管层对社交产品的一次“全面清算”,过去“先野蛮生长、后合规整改”的出海模式已彻底失效。

2026年出海社交合规生存指南:三大路径实现突围

面对全球监管收紧的大环境,出海社交开发者若想实现可持续发展,核心在于放弃侥幸心理、坚守合规底线,以下三条路径可作为破局关键:

路径一:放弃匿名模式,搭建实名/强认证体系

若产品商业模式依赖“用户匿名、无需对言行负责”的核心逻辑,需尽快完成转型。未来社交产品的核心底线是“可追溯”,即便采用昵称体系,也需搭建完善的持久账户体系,通过手机号验证、身份信息核验等强认证方式,确保用户行为可追溯、可管控,从源头降低不良信息传播风险。

路径二:将合规融入产品功能,适配全球监管要求

苹果推出的“申报年龄范围API”不应被视为运营负担,而应作为核心功能进行适配。开发者可针对不同年龄段用户设计差异化内容与功能:对未成年人开启严格的内容过滤、使用时间管理机制;对成年人提供合规范围内的社交服务。这种“分龄管理”模式,不仅能满足全球监管要求,更能提升产品公信力,成为打入欧美主流市场的核心优势。

路径三:严控AI功能风险,建立完善的内容过滤机制

随着AI技术在社交领域的广泛应用,AI陪聊、AI生成头像、AI匹配等功能成为产品创新方向,但需严格把控风险。开发者在引入AI功能前,需明确三大核心问题:AI训练数据是否合法合规?是否存在生成涉黄、涉政等敏感内容的可能?是否会诱导未成年人做出危险行为?无论采用何种大模型,均需建立严格的输出过滤机制,即便牺牲部分产品趣味性,也要确保内容绝对安全——海外市场中,单一违规内容(如AI生成的疑似儿童违规图片),即可导致应用永久下架,开发者甚至需承担刑事责任。

结语:合规是出海社交的唯一生路

2026年2月24日的下架风暴,只是全球社交领域监管收紧的一个开端。随着全球数字治理体系的不断完善,过去依赖技术红利、模式创新就能快速出海的时代已一去不复返,合规能力将成为出海社交开发者的核心竞争力。

对于在此次风暴中下架的产品,行业深感遗憾;而对于仍在坚守的开发者,需重新审视产品逻辑,主动拥抱监管、搭建完善的合规体系。唯有坚守合规底线,才能在全球出海赛道中长久立足——2026年,合规才是出海社交的唯一生存通行证。

遵守规则,方得长治久安,最后祝大家大吉大利,今晚过审!

相关推荐

# 苹果开发者续费大坑及成功续费方案!亲测有效

# AppStore敏感词排查手册,多维度分析Guideline 2.3.1隐藏功能,轻松过审。

# 如何主动提防苹果3.2f的进攻,自查防御手册(代码篇)

# 如何主动提防苹果3.2f的进攻,自查防御手册(ASO篇)

# 苹果加急审核是“绿色通道”还是“死亡陷阱”?

# 苹果开发者邮箱,突然收到11.2通知严重么?

# 不想被苹果卡审最好错开这两个提审时间

# 手撕苹果审核4.3是代码问题还是设计问题?

# 有幸和Appstore审核人员进行了一场视频会议特此记录。

开工第一天,别让AI写的代码触发3.2f封号。

作者 iOS研究院
2026年2月24日 14:17

背景

今天是农历正月初八,春节后的第一个工作日。后台有粉丝留言,迎来的开年的第一记重磅打击3.2f待终止通知。

踩线原因也是老生常谈了,严查分类之隐藏功能问题

中英对照.png

老iOSer对于这种情况已经是见怪不怪了,很多时候并非开发者想做某些Sao操作,实属无奈的多。毕竟,有业务苹果不能正面允许,不得已就采用这种上有政策下有对策的打法

原因分析

通过进一步沟通,层层抽丝剥茧。终于定位到踩到隐藏功能的导火索,在AI加持的情况下使用了非公开的API获取业务层面需要的功能权限。从业务的角度来看功能确实实现了,从苹果监管的角度来看调用了越权的API属性。通过键值对的方式Hook数据结果。

实话讲AI背大锅,对于很多跨行的开发者来说,为了满足公司的开发需求保住饭碗使用AI的方式本身没有问题。关键的问题在于,无法Review AI所编写的代码是否合规

所以,AI本质是一把双刃剑,在提高开发效率的同时,也需要额外考虑风控问题。

隐藏功能

隐藏功能的前身是苹果开发者指南中的-2.3.1条款。

主要意在通过一些动态下发的方式,直接或间接干预苹果审核所看到的内容。将符合苹果审核的内容作为A面,顺利通过审核,提高审核通过率。【俗称的AB面,也叫马甲包】

随着AppStore审核规则的加强,对于隐藏功能的判定不仅仅只是单纯的功能切换,而是上升到更为全面的元数据以及概念层面。

简单来说:

少做不做挂羊头卖狗肉的事情,苹果的算法比开发者想象中更加强大

遵守规则,方得长治久安,最后祝大家大吉大利,今晚过审!

相关推荐

# 苹果开发者续费大坑及成功续费方案!亲测有效

# AppStore敏感词排查手册,多维度分析Guideline 2.3.1隐藏功能,轻松过审。

# 如何主动提防苹果3.2f的进攻,自查防御手册(代码篇)

# 如何主动提防苹果3.2f的进攻,自查防御手册(ASO篇)

# 苹果加急审核是“绿色通道”还是“死亡陷阱”?

# 苹果开发者邮箱,突然收到11.2通知严重么?

# 不想被苹果卡审最好错开这两个提审时间

# 手撕苹果审核4.3是代码问题还是设计问题?

# 有幸和Appstore审核人员进行了一场视频会议特此记录。

AppLovin 危机升级:SDK 安全争议未平,建议移除为妙

作者 iOS研究院
2026年2月6日 14:33

背景

继 1 月做空机构 CapitalWatch 指控 AppLovin 深度涉入洗钱网络、关联东南亚 “杀猪盘” 后,这场资本风波的余震仍在持续。最新市场数据显示,截至 2026 年 2 月 5 日,AppLovin(股票代码:APP)股价已从 2025 年 11 月 10 日的 651.32 美元跌至 375.23 美元,三个月累计跌幅达 42.39% ;仅 2 月前 5 个交易日,股价就从 483 美元跌至 375.23 美元,单周跌幅超 22%,换手率最高达 6.65%,市场恐慌情绪可见一斑。

争议再发酵:从股东合规到 SDK 技术风险

此前 CapitalWatch 的报告已指出,AppLovin 主要股东 Hao Tang、Ling Tang(被指为 Hao Tang 亲属)及关联方合计持股超 28%,涉嫌通过广告业务协助转移团贷网非法集资款、东南亚诈骗资金。尽管 AppLovin 全盘否认指控,称 “无法控制个人股票买卖”,但市场对其股东层面的合规失职质疑未消 —— 作为上市公司,对主要股东的背景审查、反洗钱流程是否到位,至今仍是未解之谜。

更关键的是,这场争议已直接波及普通开发者。有行业分析指出,AppLovin 的 SDK 存在两大核心风险:一是技术合规问题,其 SDK 被曝包含指纹追踪、静默安装功能,前者可能违反用户隐私保护法规(如 GDPR、CCPA),后者则可能绕过用户授权强制安装应用,存在被应用商店下架的隐患;二是连带风险,若后续监管部门(如美国司法部、SEC)对 AppLovin 启动调查,或要求平台自查涉事 SDK,开发者可能面临 “猝不及防的下架压力”,影响应用正常运营。

股价暴跌背后:多重利空下的市场信心崩塌

从股价走势看,AppLovin 的颓势并非偶然。除了洗钱、SDK 合规争议,其商业模式本身也存在隐忧。此前已有做空机构指出,AppLovin 约 35% 的广告收入来自超休闲游戏,而这类业务的虚假点击占比或达 20% ;同时,公司 60% 的流量依赖 Meta 和 Google,若上游平台调整政策,收入可能面临断崖式下跌。

叠加最新的合规风险,机构对其估值的分歧持续扩大。截至 2 月,尽管仍有 9 家机构给出 “强力推荐” 评级,但最低目标价仅 80 美元,较当前股价隐含 75.8% 的跌幅。空头仓位也在激增,1 月 3 日单日做空量占比达 21.36%,累计空头仓位超流通股 15%,逼近熔断阈值,市场对其信心已降至冰点。

开发者应对指南:规避风险刻不容缓

面对 AppLovin 的多重危机,开发者需优先考虑业务稳定性,避免踩入合规 “雷区”:

  • 评估替换方案:若当前应用集成了 AppLovin SDK,建议尽快调研广告聚合平台,通过接入多渠道广告源,降低对单一 SDK 的依赖,避免因 SDK 下架导致收入断层;
  • 自查合规细节:重点检查 AppLovin SDK 的指纹追踪、静默安装功能是否关闭,确保用户数据收集、应用安装流程符合当地隐私法规(如 GDPR 的用户同意要求);
  • 跟踪监管动态:密切关注美国司法部、SEC 及应用商店(如苹果 App Store、Google Play)的最新政策,若出现针对 AppLovin 的调查或下架通知,需第一时间启动应急方案。

AppLovin 的案例也为整个行业敲响警钟:在选择第三方 SDK 时,除了关注流量、收益,更需穿透式审查合规情况。

毕竟,一次合规危机带来的损失,可能远超过去的收益

遵守规则,方得长治久安,最后祝大家大吉大利,今晚过审!

相关推荐

# 苹果开发者续费大坑及成功续费方案!亲测有效

# AppStore敏感词排查手册,多维度分析Guideline 2.3.1隐藏功能,轻松过审。

# 如何主动提防苹果3.2f的进攻,自查防御手册(代码篇)

# 如何主动提防苹果3.2f的进攻,自查防御手册(ASO篇)

# 苹果加急审核是“绿色通道”还是“死亡陷阱”?

# 苹果开发者邮箱,突然收到11.2通知严重么?

# 不想被苹果卡审最好错开这两个提审时间

# 手撕苹果审核4.3是代码问题还是设计问题?

# 有幸和Appstore审核人员进行了一场视频会议特此记录。

❌
❌