普通视图

发现新文章,点击刷新页面。
昨天 — 2026年1月7日首页

被 ADHD 困扰的不止罗永浩,我想分享几个能帮上忙的 AI 工具

作者 Selina
2026年1月7日 11:30

在迟到了 40 分钟之后,老罗终于在 2025 年的最后一天,站上了科技春晚的舞台。对那些枯等了许久的现场观众,他给到的除了免票,还有一个「理由」:ADHD。

ADHD 是注意缺陷多动障碍(Attention Deficit Hyperactivity Disorder)的缩写,它最常见的症状是分心、冲动、无法专注,究其核心,都是注意力调节能力的失灵,且是由于大脑发育带来的。

老实说,当代人,谁不碰上点儿拖延和分心呢——电脑上开着 50 个浏览器标签页,背景音乐还在循环播放洗脑神曲,同时人却划着手机,在小红书上晃荡了整整 20 分钟——谁还没试过呢?

一颗 ADHD 大脑也会出现上述情况,但背后有明确的病理机制。我们大脑的前额叶皮层负责做规划、踩油门、控刹车,来管理我们的行动。但 ADHD 人的前额叶活跃度,明显低于平均水平,导致执行功能就像一个延迟极高的无线遥控器:脑子里发出的指令是「现在起床」,但指令传到身体时可能已经卡顿了一个小时。

这是 ADHD 的典型症状之一:启动困难。也是普通拖延症和 ADHD 的甄别关键:普通拖延症可能是不想做、懒得做,但是 ADHD 是在大脑里已经嘶吼了几万遍,四肢却像被冻住一样。这个感觉非常难受,甚至不是事后的自责,而是当时当刻就很痛苦,还没有一丝办法。

同理,在面对分心、无法专注等情况时,ADHD 也会出现大脑不断发送专注的指令,整个人却无法执行的情况。长久以来,ADHD 被视为一种「病症」,早年间被称为「多动症」。这其实并不准确,ADHD 的大脑不是坏了,它只是在用一种「高能耗、高延迟、高爆发」的特殊算法在运行。

而现在,短视频、社交媒体导致的资讯大爆发,几乎让每个当代人出现了类似的「症状」,分心、走神、烦躁地难以集中,于是 ADHD 就成了新的「时尚挂件」。

踩不动油门?注入一点 AI

难以专注、难以启动工作,又确确实实是一种当代病——AI 的出现可谓是一丝曙光。

ADHD 对于解决启动和专注问题提供了一些灵感:传统的代办清单对于 ADHD 而言,完全是灾难——不管是用手账本还是用 To Do 类型的任务清单,不管多认真地写下「1. 写完测评报告;2. 深度清理房间;3. 学习 Python」,结果都是一样的——做不了一点。

任凭这几行字在上面挂半天,我自能刷一天的手机。

ADHD 需要的是「喂饭」级别的指令,颗粒度要尽可能的精细。比如,不要写「去健身」,而是要写「换上健身裤、拿出瑜伽垫」,把任务拆解到完全不需要思考,只需要执行的程度。

这恰恰就是 AI 最能发挥的地方:只需要把任务丢给它,让它自动拆分出一个个小步骤,方便我们执行。

从去年以来,不少应用工具都基于这个底层逻辑,开发出了不同于传统任务清单、为 A 人贴身打造的应用工具。

【PlanCoach】

这是国内比较早开始做任务拆分的应用,获得了小红书黑客松一等奖。开发者的理念是:把一个步骤连续拆分,直到能动起来为止。这也的确是 app 呈现出来的样子:输入想要做的事务,AI 会即刻自动拆分,执行的时候支持语音播报、互动,解放双手。

PlanCoach 比较有特色的地方是「角色形象」,有管家、大臣、男仆等不同的教练角色形象,不同的角色「说话」方式也不同,很有趣味性。在 PlanCoach,你甚至可以看到吴京……

PlanCoach 提供几种不同的修改计划的方式,最简单的是完全重新生成——这就是利用 AI 抽卡的底层原理,同一个指令但抽卡抽出不同的效果——注意一旦重新抽就是全局式的修改,完全改头换面。针对局部步骤的修改,可以点击「编辑步骤」,并且提供自行修改,或者让 AI 帮你修改的方式。

整体上,PlanCoach 的开发思路是冲着 ADHD 去的,开发者在小红书上,经常更新思路和想法,希望能覆盖式地解决启动、执行等问题的同时,也避开诸如感官过载、容易分心等问题。

目前仅支持 iOS 客户端,iPad 版可以在 mac 上使用。注意:PlanCoach 正在执行阶梯型涨价,终身会员的价格会逐步拉高,最终目标超过 200 元。考虑到 ADHD 的友友们在做决策这方面也会瞻前顾后,这个可需要注意喔。

【滚雪球】

这个可爱的名字背后是有深意的:一步接着一步,从小步骤开始,想滚雪球一样,完成一件大任务。

同样基于任务拆分的形式,滚雪球比 PlanCoach 更简洁一点,更强调的是每一次完成的反馈——在设计上,每一个具体步骤都需要点击以进入下一步。也就是说每一次都只有一个步骤显示在屏幕上,每一个步骤还可以倒计时,要么完成、要么跳过,才能进入下一步。

好处是有一种「摘果子」的感觉,走一步摘一个,反馈链条缩到最短。反面是:一旦进入心流,可能会直接忘记需要点——比如我在使用过程中经常就出现这种情况,成功起床洗漱了,然后……直接忘了手机里雪球还在滚。

当然,每个人的需求不一样,对我而言,只要能让我成功启动,后面忘了就忘了吧。可以说滚雪球更侧重于启动,PlanCoach 更侧重整体安排。

滚雪球的特色在于精力管理:每一次完成任务,都会有一个记录心情、状态的统计页面。每一个小步骤里也可以通过点击空白处,记录当下的心情和状态,这些数据会进入「我的状态」板块。

这样有助于直观地看到自己的精力变化周期,比如我就是很典型的午晚间人,上午动不了一点,午饭后状态开始爬升,晚饭后开始回落。

掌握自己的精力变化周期,可以因时制宜地安排任务,对 ADHD 来说很有用——强迫自己是没有用的。

【Ziea】

上面两款都是手机 app,成也手机败也手机——我已经数不清多少次,明明是要去点开 app,却在解锁后突然一个大拐弯,点开了小红书,从此坠入时间黑洞……

Ziea 就把任务拆分做到了硬件里,实现专注的目标。

从演示 demo 中可以看到,基础逻辑是一样的:任务拆分、整合进日程管理、安排番茄钟,全程只需要跟产品互动。

这款产品目前还处于研发阶段,没有公开发售,但是思路相当值得借鉴——专注型软件一定要做成手机 app 吗?

归根到底,该怎么避免被手机带跑偏?别碰手机就是了嘛。今天我们已经有那么多智能设备了:手表、手环、眼镜,甚至是戒指,这些设备作为载体,会不会更加适配对于专注的渴望和追求呢——开发者们快想想!

当然,任务拆分型 app 现在已经有很多选择了,极有可能出现下载了十几个 app,平均每个用三天到一礼拜,然后就被忘在手机的角落里吃灰

千万不要因此感到挫败或自责,ADHD 的大脑永远在追求新鲜感与更低的启动门槛,这本身就是我们生命力的一部分。如果你发现一个工具没法持续地吸引你,那大概率说明它的设计逻辑并不适配你大脑的系统版本。

只要这个 app 能在某个瞬间,帮你把脑子里那台刹车不好使的法拉利成功推出起跑线,它的使命就已经完成了。记住,你才是赛车手,所有的工具都只是路标和护栏。生活不必总是一板一眼的直线行驶,哪怕是蛇形走位,只要你在前进,那就是属于你的胜利。

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

爱范儿 | 原文链接 · 查看评论 · 新浪微博


Swift 6.2 列传(第十六篇):阿朱的“易容术”与阿紫的“毒药测试”

2026年1月7日 08:57
0️⃣ 🐼 序章:雁门关前的技术难题 雁门关,数据流与现实交错的虚拟战场。 大熊猫侯佩正对着一块全息屏幕发呆,屏幕上是无数条交易记录,他正努力寻找他藏匿的竹笋基金。他用手摸了摸自己的头顶,确定了头绝对
昨天以前首页

Swift 6.2 列传(第十五篇):王语嫣的《万剑归宗》与 InlineArray

2026年1月4日 11:24
0️⃣ 🐼 序章:琅嬛福地的“内存”迷局 琅嬛福地,天山童姥遗留的虚拟数据中心。 这里是存储着天下所有数据结构秘籍的宝库。大熊猫侯佩穿梭在巨大的全息卷轴之间,背景音乐是《天龙八部》的BGM,他一边走一

库克发了张疑似 AI 生图,把反 AI 神剧《同乐者》给背刺了

作者 苏伟鸿
2026年1月1日 13:12

苹果全新大戏《同乐者》终于完结,「炸裂」的大结局,大家看了吗?

为了庆祝大结局播出和圣诞节,苹果 CEO 蒂姆 · 库克在 X 上发布了一条推文,却引发了意料之外的争议。

油腻的质感、逻辑不通的细节、毫无亮点的画面,引得网友们纷纷留言质问:

这是用 AI 生成的吗?

反 AI 电视剧,被 AI 背刺?

我相信,如果是 Netflix 高管发了一张《怪奇物语》的 AI 图,也不会造成这么大的舆论争议,尽管这部剧集比《同乐者》要火不少。

但不管是《同乐者》,还是放大到 Apple TV 平台本身,都带有强烈的「反 AI」气质。

给没看过《同乐者》的朋友们简单讲述一下剧情:一种神秘的末世病毒席卷了全世界,向世人强行灌输了乐观和满足的情绪,形成统一的「蜂巢意识」。一位悲观的畅销书作家卡罗尔发现自己免疫这种病毒,与此同时病毒群体正在试图转化她和其他免疫者。

剧集在海内外口碑都不错:MTC 斩获 87 的综合评分,IMDB 用户评分 8.1,豆瓣评分 8.3,算得上今年名列前茅的好剧。

这部剧集《绝命毒师》《风骚律师》核心主创文斯 · 吉里根打造,独特的美学风格和镜头语言和这两部经典作品一脉相承。苹果还给出了 Apple TV 剧集史上最高的预算——单集 1500 万美元起,让剧集得以充满各种实拍镜头的大场面。凭借《风骚律师》两度提名艾美奖的主演蕾亚 · 塞洪,也已经靠这部剧拿下金球奖提名。

剧中的一个情节让人印象深刻:卡罗尔和这个蜂巢意识的病毒群体进行接触后发现,这群「同乐者」愿意为她做任何事,满足她任何要求,还集合了人类的智慧和记忆。

不少观众看剧时感到莫名熟悉:这群谄媚、随时帮助、没有其他感情、充满知识的「同乐者」,不就是 ChatGPT 吗?

不过,对于这种言论,文斯 · 吉里根回应称,他其实从来没用过 AI 聊天机器人,创作《同乐者》时也没考虑到 AI。

我讨厌 AI,AI 是世界上最昂贵、最耗能的抄袭机器。

▲左: 蕾亚 · 塞洪;右:文斯 · 吉里根

吉里根的立场也直接在剧集制作中体现,片尾字幕特别标注了「本节目由人类制作」,在播出时就引发了不少讨论。

虽然苹果公司在 AI 技术道路上一往无前,但苹果和各行各业的创作者长期以来保持着密切联系,其实人们更愿意看到苹果继续重视「创作」背后的人文价值。

在《同乐者》播出期间,Apple TV 还发布了新片头和制作幕后:这个看起来像是动画渲染的片头,居然大部分是实拍的成果。

这种「全手工」到近乎有点笨拙的创作方式,也被大众解读为苹果对「创作」的尊重,赢得了不少掌声。

在主创旗帜鲜明反对 AI、剧集被当作 AI 寓言、苹果尊重创作的历史种种前提下,库克发布了一张疑似 AI 生成的图来庆祝剧集完结,自然引起了人们的不满和质疑。

所以这张图真的是 AI 生成的结果吗?苹果没有对此作出回应,有人联系了图片作者 Keith Thomson,一位现代画家,对方给出了这样的回答:

我无法对具体客户项目发表评论。一般来说,我总是手绘绘画,有时也会用到标准的数字工具。

这个似是而非的回答,完全没解决大众的困惑。在这条推文下方,以及更多社交平台上,网友们已经吵成了一锅粥。

一些专业的绘画或科技人士认为,这张图片有着人类手绘的笔触痕迹,并非 AI 出品。

Apple TV 官方账号很快也加入战局,转发推文的同时强调「这是由 Keith Thomson 用 MacBook Pro 创作」,似乎企图用「MacBook Pro」这个老牌创作工具的金字招牌,来为配图正名。

但认为是 AI 出品的网友证据更加充分:图片的牛奶盒同时标注了「全脂牛奶」和「脱脂牛奶」;盒子上的迷宫也没有解法;画面充满了灰蒙蒙的噪点,这些都是典型的 AI 生图特征。

也有其他艺术家将这张图和 Keith Thomson 的作品集进行比对,不管是风格、笔触、画面元素的处理方式,都非常不同。

一些网友也推断,苹果大概率是向这位艺术家买了张配图,结果 Keith Thomson 使用「标准的数字工具」,例如一个用自己作品集训练出来的 AI,生成一张图片再动手改了改交差。

烂图比 AI 图更值得声讨

在没有更多新信息和证据的前提下,这场「是不是 AI」的争论已经成为了一场「罗生门」——观点不同的双方各执一词,事情真相已经扑朔迷离。

著名苹果评论员 John Gruber 直接引用所谓的「奥卡姆剃刀」原则进行推论:在种种复杂的可能性中,最接近事实的往往最简单,Keith Thomson 就是用了 AI。

▲ AI 检测工具也认为这张图是 AI 出品

艺术家本人模棱两可的态度,其实也坐实了这个结论——对于大部分创作者来说,自己辛苦产出的作品被打为 AI,是绝对不可以接受的,都会第一时间跳出来反驳。

况且,争论进行到这一步,这张图究竟是不是 AI 生成,其实已经不重要了。

就质量本身而言,这张图片细节拙劣,画面粗糙,你很难承认它有什么审美上的价值——这和大部分 AI 产图一样。

▲ 右边是我使用 Nano Banana 生成主题相似的图片

AI 生成的低质量图片,和人类粗制滥造的作品,本质上真的有区别吗?本质上不都是一些质量很差、毫无美感的图片?

我们为什么会对 AI 产出嗤之以鼻?因为我们的内心都默认,人类用时间、知识、经验浇灌出来的创作,才是真正优秀的作品,AI 更多是不需要心血、量产的「垃圾」。

但怎么用 AI、用 AI 创作出什么,其实都是人决定的,人的审美、品位决定了 AI 作品的高度。

当创作者不愿意去花时间构思,也没有任何好创意,只想躺着赚快钱,AI 就成替罪羊——可惜的是,这样的创作者现在越来越多,因此我们的生活充满了 AI 生产出来的废料,让大众进一步排斥 AI 创作。

▲ 可口可乐今年的 AI 假日广告,因为效果太糟糕被吐槽

如果想要去把事情做好,那 AI 就会带来前所未有的可能性。

这个月发布的小米 17 Ultra 徕卡定制版手机,有一个独占的「徕卡一瞬」功能,可以让拍出来的图片模拟出徕卡相机 M9 的风格和质感,也就是所谓的「德味」。

实现的方式,并非单纯的滤镜和照片色彩管理,而是小米和徕卡用大量 M9 拍出的照片,训练出一个大模型,用这个 AI 把图片「修」出德味。

这些「AI 德味」的照片在文字上会存在一定的幻觉,「AI 篡改照片」也引起了一些非议,有人认为是对徕卡纪实摄影传承的背叛。

但在评测的过程中,爱范儿的编辑们都被这些色彩浓郁的照片打动了,认为这台手机确实还原出我们心中的德味,丝毫不介意它是否「AI」。

更重要的是,「德味」这种曾经只属于部分摄影爱好者的审美和创作权,被 AI 复制后,走向了更多的人。

▲ AI 还原得了德味,未必能还原文字

这几年,所谓的「AI 艺术家」也正在全球崭露头角,他们不避讳自己作品中的 AI 元素,反而利用 AI 生成那种不按常理出牌的效果,创作出风味前所未有的作品,带来了一种全新的审美。

▲ 汤海清是一位使用 AI 创作数字影像的艺术家,作品常常结合民俗和梦核元素

我们暂且不去考虑关于 AI 抄袭、量产、「没有灵魂」的争议,单就结果而言,能用 AI 产出好的作品,其实一样会受到大家的欢迎。

回过头来看苹果和 Keith Thomson 这件事,其实给全世界的企业和创作者都上了很好的一课。

即使苹果很可能真的是被 Keith Thomson 「诓骗」,买手绘图收到一张 AI 图,对于那个自诩很有「品味」的苹果来说,也不应该启用这张劣质的配图,来宣传《同乐者》这样极具审美水准的影视作品。

而对于创作者来说,如果你不想自己的作品被打为「AI 生成」,那只能把它做好,而且比越来越强的 AI 还要更好。

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

爱范儿 | 原文链接 · 查看评论 · 新浪微博


HBuilderX 4.87 无法正常读取 macOS 环境配置的解决方案

作者 狗鸽
2025年12月31日 22:27

我的电脑配置是 macOS Sequoia 15.6.1(M2 芯片)。最近使用 HBuilderX 4.87 打包 APP 时出现卡住、无法正常打包的问题。本文给出解决方案。

隔了几个月,最近要用 uni-app 打包一下 APP。我像之前那样打开了 HBuilderX,更新并打包 Android APP,却发现提示我需要配置 Node.js。

我很奇怪,我都是用 fnm 管理 Node.js,几个项目 Node.js 用得好好的,没啥问题啊,为什么会这样呢?🤔

我百思不得其解,直到我打开了 HBuilderX 提示的 关联链接

HBuilderX macOS 环境变量

Hbuilder X 从 4.41 版本开始调整 cli 项目使用本地的 node 执行编译,如果用户未安装并正确配置 Node.js 会警告报错。

目前只从 bash 中读取环境变量,需要确保配置到 bash 中

Bro,从 4.41 版本更新到 4.87 版本,还是不能从 macOS 10.15 开始默认使用的 zsh 读取环境变量,体验真是太差了。😅

我搜索了 DCloud 论坛里面,有好哥哥提到将 ~/.zshrc 里的内容复制粘贴到 ~/.bash_profile 就能用了。如果你感兴趣可以尝试一下这个方案,但我尝试时发现还是会卡住无法继续,HBuilderX 日志提示 fnm 不存在。

进一步地,如果我去掉了 ~/.bash_profile 里面的 fnm,只是按照官方指南那样设置 Node.js PATH,仍然没办法正常使用。

# HBuilderX
export PATH="$PATH:$HOME/Library/Application Support/fnm/aliases/default/bin"

# Android Studio
export ANDROID_HOME=$HOME/Library/Android/sdk
export ANDROID_SDK_ROOT=$HOME/Library/Android/sdk
export PATH=$PATH:$ANDROID_SDK_ROOT/emulator
export PATH=$PATH:$ANDROID_SDK_ROOT/tools
export PATH=$PATH:$ANDROID_SDK_ROOT/tools/bin
export PATH=$PATH:$ANDROID_SDK_ROOT/platform-tools

# Java
alias use-java17='/usr/libexec/java_home -v 17 --exec java -version'
alias use-java8='/usr/libexec/java_home -v 1.8 --exec java -version'

直到我偶然设置了全局的 Node.js 版本,重启 HBuilderX,结果可以正常打包了!但如果我去掉 ~/.bash_profile 里面的 Node.js PATH,重启 HBuilderX,又没法正常打包了!

我综合几次实验,得出的结论是,~/.bash_profile 里面 Node.js PATH 只起到了一个欺骗 HBuilderX 的作用,并不会使用里面 PATH 对应的 Node.js,实际上使用的是 fnm 在全局设置的 Node.js。我完全无法理解这种事情,也不好说这是 fnm 的弊端,还是 HBuilderX 的缺陷 😱

如果你尝试好哥哥的方案无法正常运行,那可以尝试一下我的方案:

  1. 设置 ~/.bash_profile,如上所示;
  2. 切换到 ~ 目录下,运行一下 fnm use [打包项目所需的 Node.js]
  3. 重启 HBuilderX,打包 APP。

希望对你有所帮助!元旦快乐!


EDIT:

补充几个注意点:

  1. 我使用 Homebrew 来安装 fnm,你的实际路径可能和我得不一样,在设置 ~/.bash_profile 时可能需要调整。
  2. 别忘了设置 ~/.bash_profile 后要执行 source 命令。我为了稳妥起见,执行了两条。
source ~/.bash_profile
source ~/.zshrc
  1. 论坛里的解决方案参考:ask.dcloud.net.cn/question/20…ask.dcloud.net.cn/question/20…ask.dcloud.net.cn/question/20…

鸿蒙激励的羊毛,你"薅"到了么?

作者 iOS研究院
2025年12月30日 15:28

背景

鸿蒙应用开发者激励计划2025,是由华为发起的开发者支持项目,旨在通过提供现金激励,鼓励开发者参与鸿蒙应用、游戏(含游戏App和小游戏,以下如无特指均使用“游戏”统一描述)、元服务的开发,以推动鸿蒙生态的建设和繁荣发展。

距离鸿蒙激励还有最后一天。

跟进政策走

听人说,有些小公司专搞 “面向补贴编程”,靠反复上包薅政策羊毛

我觉得吧,这种路子对刚入门的开发者来说,确实能赚点小钱、当个入门激励。

尤其对于新手来说,比起苹果审核的冷漠,国内安卓市场的内卷,谷歌市场的封杀。鸿蒙开发确实更适合,用自身技能变现+紧跟政策红利。

强者思维

你不是缺机会,你是缺了一双发现机会的眼睛。

思维对比:

  • 有钱人:专注赚钱机会
  • 普通人:专注过程困难

这种深植于骨髓的习惯性思维,短期内看似无关紧要,但拉长到五年、十年,便造就了人与人之间无法逾越的鸿沟。

世界上不缺赚钱的机会,只缺“看见”机会的人。

遵守规则,方得长治久安,最后祝大家大吉大利,今晚过审!

相关推荐

# 苹果开发者续费大坑及成功续费方案!亲测有效

# AppStore敏感词排查手册,多维度分析Guideline 2.3.1隐藏功能,轻松过审。

# 如何主动提防苹果3.2f的进攻,自查防御手册(代码篇)

# 如何主动提防苹果3.2f的进攻,自查防御手册(ASO篇)

# 苹果加急审核是“绿色通道”还是“死亡陷阱”?

# 苹果开发者邮箱,突然收到11.2通知严重么?

# 不想被苹果卡审最好错开这两个提审时间

# 手撕苹果审核4.3是代码问题还是设计问题?

# 有幸和Appstore审核人员进行了一场视频会议特此记录。

安卓原生写uniapp插件手把手教学调试、打包、发布。

2025年12月30日 10:32

前言

大家好呀,作为一个前端工程师,对于使用uniapp来写app大家应该并不陌生,但是对于封装第三方提供的安卓原生sdk给到uniapp当中去使用,并没有做过,前段时间刚好有这么需求,做了一个uniapp链接蓝牙设备,并且调用安卓原生sdk的能力的这么一个需求,想着写一个笔记,都不敢叫做教程的一个文章,有什么问题希望大家可以指教。


前期准备

  1. java的sdk,由于是涉及到安卓的原生开发,所以这个是安卓的开发环境还是要搭建的,教程一搜一大把,我就不赘述了
  2. Android studio,安卓原生的IDE编码工具
  3. 下载uniapp官网中的示例包,后来用于调试原生安卓代码时需要用的到,nativesupport.dcloud.net.cn/NativePlugi…

image.png


官方示例

前期准备做好,我们使用android_studio打开UniPlugin-Hello-AS这个项目,等待依赖包安装完成。

安装依赖包会比较慢,建议使用科学上网,然后在Android-studio上设置一下代理,速度会快很多

设置代理

image.png

官方示例代码结构

image.png

蓝牙扫描插件

接下来,我们要做一个蓝牙扫描器的插件,提供给uniapp的项目使用。分析一下需求

  1. 功能包括,连接蓝牙扫描器、开始扫描、获取扫描到的数据、停止扫描。
  2. 综上所述的功能要求,我们不需要安卓原生这边写界面,只需要提供调用sdk的能力。所以我们参考官方的uniplugin_module这个插件

新建一个蓝牙扫描器的安卓原生模块

我们在官方示例的项目上,新建一个blue_scan的模块

image.png

选择Android Library,填写模块名称

image.png

新建之后,等待依赖包安装完成。 image.png

在模块的build.gradle依赖文件上加上插件编写必要的依赖包,我们可以选择直接从uniplugin_module下的build.gradle中的dependencies依赖项拷贝过来,所以我们模块的build.gradle下的dependencies就应该变成下面的样子。

image.png

dependencies {

    compileOnly fileTree(dir: 'libs', include: ['*.jar'])
    compileOnly fileTree(dir: '../app/libs', include: ['uniapp-v8-release.aar'])
    compileOnly 'androidx.recyclerview:recyclerview:1.0.0'
    compileOnly 'androidx.legacy:legacy-support-v4:1.0.0'
    compileOnly 'androidx.appcompat:appcompat:1.0.0'
    implementation 'com.alibaba:fastjson:1.2.83'
    implementation 'com.facebook.fresco:fresco:1.13.0'


    implementation 'androidx.core:core-ktx:1.17.0'
    implementation 'androidx.appcompat:appcompat:1.7.1'
    implementation 'com.google.android.material:material:1.13.0'
    testImplementation 'junit:junit:4.13.2'
    androidTestImplementation 'androidx.test.ext:junit:1.3.0'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0'
}

然后同步一下依赖,就会自动安装。

image.png

插件代码编写

到这里,我们的插件项目已经新建好了,接下来我们可以着手写对应的代码了

新建一个BlueScan类

在我们项目根目录下的 /blue_scan/src/main/java/com.example.blue_scan 下新建一个名为BlueScan的java的class类

image.png

插件类必须继承UniModule,所有我们将代码修改成下面的样子。

package com.example.blue_scan;
import io.dcloud.feature.uniapp.common.UniModule;

public class BlueScan extends UniModule {

}

连接蓝牙扫描拍,与断开蓝牙扫描拍函数

有几个注意点:

  1. 函数必须是public
  2. 需要使用@UniJSMethod(uiThread = true)装饰器
  3. 安卓原生给uniapp传参需要通过UniJSCallback
package com.example.blue_scan;
import io.dcloud.feature.uniapp.annotation.UniJSMethod;
import io.dcloud.feature.uniapp.bridge.UniJSCallback;
import io.dcloud.feature.uniapp.common.UniModule;

public class BlueScan extends UniModule {
    @UniJSMethod(uiThread = true)
    public void connectBlueScan(UniJSCallback callback){
    // 连接蓝牙扫描拍成功,这里的true可以在uniapp代码中拿到,所以通过这种方式传递数据
        callback.invoke(true);
    }

    @UniJSMethod(uiThread = true)
    public void disconnectBlueScan(UniJSCallback callback){
        // 断开蓝牙扫描器
        callback.invoke(true);
    }
}

获取蓝牙扫描拍的数据

这里跟上面的函数会有一点不一样,因为蓝牙扫描拍的数据传递是需要多次传递的,也可以简单理解需要一个类似于'长链接的概念',这里有一个需要注意的点!!!,在uniapp的代码中需要通过对象嵌套函数的方式传这个回调函数。否则只有第一次会调用成功,这个应该跟安卓的垃圾回收机制有关。

@UniJSMethod(uiThread = true)
public void getBlueScanData(UniJSCallback callback){
    // 传递蓝牙扫描拍的数据,需要多次传递的话,就使用invokeAndKeepAlive的方式
    callback.invokeAndKeepAlive(1);
}

uniapp 调用时(这里是伪代码),看下面代码的方式才能保证invokeAndKeepAlive的方式正常生效。

const callBackObj = {
    // 需要使用对象嵌套函数的方式传递,才能保证正常传递数据
    fn:function(data){
        
    }
}

blueScan.getBlueScanData(callBackObj.fn)

安卓项目中注册插件模块

image.png

插件调试

安卓原生的代码调试,最方便的方式就是官方示例中的方式,通过打包uniapp的本地资源放到安卓项目中进行调试,因为uniapp是无法看到安卓原生插件打印的一些输出的,并且uniapp是需要打包自定义基座才能使用安卓原生插件,每次改动插件都需要重新打包自定义基座,效率非常慢。所以我们下面使用官方示例中的方式

uniapp项目代码中,引用插件,并且调用。

uniapp中写如下代码。

<template>
<view class="content">
<button @tap="connectBlueScan">连接蓝牙</button>
<button @tap="disconnectBlueScan">断开蓝牙</button>
<button @tap="getBlueScan">获取蓝牙数据</button>
</view>
</template>

<script>
// 蓝牙原生插件,这里的名称就是在安卓项目中dcloud_uniplugins.json中写的name
const blueScanNativePlugin = uni.requireNativePlugin('BlueScan');
export default {
data() {
return {
title: 'Hello'
}
},
onLoad() {

},
methods: {
connectBlueScan(){
blueScanNativePlugin.connectBlueScan((data)=>{
console.log('connectBlueScan---',data)
})
},
disconnectBlueScan(){
blueScanNativePlugin.disconnectBlueScan((data)=>{
console.log('disconnectBlueScan-----',data)
})
},
getBlueScan(){

const callBack = {
fn:function(data){
console.log('getBlueScan----',data)
}
}

blueScanNativePlugin.getBlueScanData(callBack.fn)
},
}
}
</script>

<style>
.content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}

.logo {
height: 200rpx;
width: 200rpx;
margin-top: 200rpx;
margin-left: auto;
margin-right: auto;
margin-bottom: 50rpx;
}

.text-area {
display: flex;
justify-content: center;
}

.title {
font-size: 36rpx;
color: #8f8f94;
}
</style>

到uniapp开发者后台创建android的离线key

image.png

这个时候会需要用到安卓的签名信息,我们生成一个安卓的签名,我们通过keytool命令行的形式来生成,下面只是示例,具体的信息可以根据自己的要求来填写。

keytool -genkey -v -keystore my-release-key.jks -keyalg RSA -keysize 2048 -validity 10000 -alias my-alias

查看签名中的信息,到后台中进入填入。

# 查看签名信息
keytool -list -v -keystore my-release-key.jks

image.png

保存好之后,就可以生成的离线key,填写到安卓项目中

image.png

把安卓的签名文件放到安卓项目中的app文件夹的根目录上,并填写对应的签名配置

image.png

在uniapp项目中新建一个nativeplugins的文件夹,这个名称是固定的,存放安卓插件内容。在这个目录下新建一个目录插件的名称,我们就叫BlueScan,在下面新建一个package.json。

{
  "name": "BlueScan", 
  "id": "BlueScan", // ID必须唯一
  "version": "1.0.0",
  "_dp_type": "nativeplugin",
  "_dp_nativeplugin": {
    "android": {
      "hooksClass": "",
      "integrateType": "aar",
  "plugins": [{
  "type": "module",
  "name": "BlueScan", // 在uniapp这边调用的时候插件的名称
  "class": "com.example.blue_scan.BlueScan" // 安卓原生项目上的包名
  }]
    }
  }
}
Z

填写好内容之后,我们在uniapp项目中的mainfest.json配置上勾选这个安卓原生插件。

image.png

勾选完之后,我们就可以将uniapp代码打包本地资源。

image.png

打包出来的资源放到安卓项目下app/src/main/assets/apps

image.png

修改dcloud_control.xml文件中的appid,为uniapp资源包的文件名

image.png

如果需要显示出uniapp这边的打印日志到安卓这边的控制台上的话,就修改一下dcloud_control.xml文件,加上下面的代码,需要注意加上了这个代码的话,最好每次都删除App重新安装,否则会出现代码缓存的问题,并且在生产发布的时候需要去掉这段代码

image.png

运行到真机

前面所有的工作都算做完了,没有问题的话,我们连接上手机,然后直接运行到真机,当当当~能正常看到打印的日志

image.png

打包插件到uniapp项目中使用

安卓打包插件

image.png

拷贝打包出的插件

image.png

放到uniapp项目的nativeplugins目录下对应的插件文件夹下,新建一个android的文件夹,把拷贝的插件包放进去

image.png

然后运行自定义基座或者直接打包就可以直接使用了,uniapp标准基座是不支持自定义插件的

Swift 6.2 列传(第十四篇):岳灵珊的寻人启事与 Task Naming

2025年12月30日 10:21

在这里插入图片描述

摘要:在成千上万个并发任务的洪流中,如何精准定位那个“负心”的 Bug?Swift 6.2 带来的 Task Naming 就像是给每个游荡的灵魂挂上了一个“身份铭牌”。本文将借大熊猫侯佩与岳灵珊在赛博华山的奇遇,为您解析 SE-0469 的奥秘。

0️⃣ 🐼 序章:赛博华山的“无名”孤魂

赛博华山,思过崖服务器节点。

这里的云雾不是水汽,而是液氮冷却系统泄漏的白烟。大熊猫侯佩正坐在一块全息投影的岩石上,手里捧着一盒“紫霞神功”牌自热竹笋火锅,吃得津津有味。

“味道不错,就是有点烫嘴……”侯佩吹了吹热气,习惯性地摸了摸头顶——那里毛发浓密,绝对没有秃,这让他感到无比安心。作为一名经常迷路的路痴,他刚才本来想去峨眉山看妹子,结果导航漂移,不知怎么就溜达到华山来了。

在这里插入图片描述

忽然,一阵凄婉的哭声从代码堆栈的深处传来。

“平之……平之……你在哪条线程里啊?我找不到你……”

侯佩定睛一看,只见一位身着碧绿衫子的少女,正对着满屏滚动的 Log 日志垂泪。她容貌清丽,却神色凄苦,正是华山派掌门岳不群之女,岳灵珊

“岳姑娘?”侯佩擦了擦嘴角的红油,“你在这哭什么?林平之那小子又跑路了?”

岳灵珊抬起泪眼,指着屏幕上密密麻麻的 Task 列表:“侯大哥,我写了一万个并发任务去搜索‘辟邪剑谱’的下落。刚才有一个任务抛出了异常(Error),但我不知道是哪一个!它们全都长得一模一样,都是匿名的 Task,就像是一万个没有脸的人……我找不到我的平之了!”

在这里插入图片描述

侯佩凑过去一看,果然,调试器里的任务全是 Unspecified,根本分不清谁是谁。

在本次大冒险中,您将学到如下内容:

  • 0️⃣ 🐼 序章:赛博华山的“无名”孤魂
  • 1️⃣ 🏷️ 拒绝匿名:给任务一张身份证
  • 简单的起名艺术
  • 2️⃣ 🗞️ 实战演练:江湖小报的并发采集
  • 3️⃣ 💔 岳灵珊的顿悟
  • 4️⃣ 🐼 熊猫的哲学时刻
  • 5️⃣ 🛑 尾声:竹笋的收纳难题

“唉,”侯佩叹了口气,颇为同情,“这就是‘匿名并发’的痛啊。出了事,想找个背锅的都找不到。不过,Swift 6.2 给了我们一招‘实名制’剑法,正好能解你的相思之苦。”

这便是 SE-0469: Task Naming

在这里插入图片描述


1️⃣ 🏷️ 拒绝匿名:给任务一张身份证

在这里插入图片描述

在 Swift 6.2 之前,创建 Task 就像是华山派招收了一批蒙面弟子,干活的时候挺卖力,但一旦有人偷懒或者走火入魔(Crash/Hang),你根本不知道是谁干的。

岳灵珊擦干眼泪:“你是说,我可以给平之……哦不,给任务起名字?”

“没错!”侯佩打了个响指,“SE-0469 允许我们在创建任务时,通过 name 参数给它挂个牌。无论是调试还是日志记录,都能直接看到名字。”

在这里插入图片描述

这套 API 非常简单直观:当使用 Task.init()Task.detached() 创建新任务,或者在任务组中使用 addTask() 时,都可以传入一个字符串作为名字。

简单的起名艺术

侯佩当即在全息屏上演示了一段代码:

// 以前我们只能盲人摸象
// 现在,我们可以给任务赐名!
let task = Task(name: "寻找林平之专用任务") {
    // 在任务内部,我们可以读取当前的名字
    // 如果没有名字,就是 "Unknown"(无名氏)
    print("当前运行的任务是: \(Task.name ?? "Unknown")")
    
    // 假装在干活
    try? await Task.sleep(for: .seconds(1))
}

在这里插入图片描述

“看,”侯佩指着控制台,“现在它不再是冷冰冰的内存地址,而是一个有血有肉、有名字的‘寻找林平之专用任务’了。”

2️⃣ 🗞️ 实战演练:江湖小报的并发采集

“光有个名字有什么用?”岳灵珊还是有点愁眉不展,“我有那么多个任务在跑,万一出错的是第 9527 号呢?”

“问得好!”侯佩咬了一口竹笋,摆出一副高深莫测的样子(虽然嘴角还挂着笋渣),“这名字不仅可以硬编码,还支持字符串插值!这在处理批量任务时简直是神技。”

在这里插入图片描述

假设我们需要构建一个结构体来通过网络加载江湖新闻:

struct NewsStory: Decodable, Identifiable {
    let id: Int
    let title: String // 比如 "令狐冲因酗酒被罚款"
    let strap: String
    let url: URL
}

现在,我们使用 TaskGroup 派出多名探子(子任务)去打探消息。如果有探子回报失败,我们需要立刻知道是哪一路探子出了问题。

let stories = await withTaskGroup { group in
    for i in 1...5 {
        // 关键点来了!👇
        // 我们在添加任务时,动态地给它生成了名字: "Stories 1", "Stories 2"...
        // 这就像是岳不群给弟子们排辈分,一目了然。
        group.addTask(name: "江湖快报分队-\(i)") {
            do {
                let url = URL(string: "https://hws.dev/news-\(i).json")!
                let (data, _) = try await URLSession.shared.data(from: url)
                return try JSONDecoder().decode([NewsStory].self, from: data)
            } catch {
                // 🚨 出事了!
                // 这里我们可以直接打印出 Task.name
                // 输出示例:"Loading 江湖快报分队-3 failed."
                // 岳灵珊瞬间就能知道是第 3 分队被青城派截杀了!
                print("加载失败,肇事者是: \(Task.name ?? "Unknown")")
                return []
            }
        }
    }

    var allStories = [NewsStory]()

    // 收集情报
    for await stories in group {
        allStories.append(contentsOf: stories)
    }

    // 按 ID 排序,保持队形
    return allStories.sorted { $0.id > $1.id }
}

print(stories)

3️⃣ 💔 岳灵珊的顿悟

看完这段代码,岳灵珊破涕为笑:“太好了!这样一来,如果‘寻找平之’的任务失败了,我就能立刻知道是哪一次尝试失败的,是在福州失败的,还是在洛阳失败的,再也不用对着虚空哭泣了。”

在这里插入图片描述

侯佩点点头,语重心长地说:“在并发的世界里,可见性(Visibility) 就是生命线。一个未命名的任务,就是 unpredictable(不可预测)的风险。给了它名字,就是给了它责任。如果它跑路了(Rogue Task),我们至少知道通缉令上该写谁的名字。”

岳灵珊看着屏幕上一个个清晰的任务名称,眼中闪过一丝复杂的神色:“是啊,名字很重要。可惜,有些人的名字,刻在了心上,却在江湖里丢了……”

在这里插入图片描述

“停停停!”侯佩赶紧打断她,生怕她又唱起那首福建山歌,“咱们是搞技术的,不兴搞伤痕文学。现在的重点是,你的 Debug 效率提升了 1000%!”

4️⃣ 🐼 熊猫的哲学时刻

侯佩站起身,拍了拍屁股上的灰尘(虽然是全息投影,但他觉得要有仪式感)。

“其实,给代码起名字和做熊一样。我叫侯佩,所以我知道我要吃竹笋,我知道我头绝对不秃,我知道我要走哪条路(虽然经常走错)。如果我只是一只‘Anonymous Panda’,那我可能早就被抓去动物园打工了。”

在这里插入图片描述

“善用 Task Naming,”侯佩总结道,“它不会增加运行时的负担,但在你焦头烂额修 Bug 的时候,它就是那个为你指点迷津的‘风清扬’。”

5️⃣ 🛑 尾声:竹笋的收纳难题

帮岳灵珊解决了心病,侯佩准备收拾东西离开赛博华山。他看着自己还没吃完的一大堆竹笋,陷入了沉思。

在这里插入图片描述

“这竹笋太多了,”侯佩嘟囔着,“用普通的 Array 装吧,太灵活,内存跳来跳去的,影响我拔刀(吃笋)的速度。用 Tuple 元组装吧,固定是固定了,但这写法也太丑了,而且还没法用下标循环访问……”

在这里插入图片描述

岳灵珊看着侯佩对着一堆竹笋发愁,忍不住问道:“侯大哥,你是想要一个既有元组的‘固定大小’超能力,又有数组的‘下标访问’便捷性的容器吗?”

侯佩眼睛一亮:“知我者,岳姑娘也!难道 Swift 6.2 连这个都有?”

在这里插入图片描述

岳灵珊微微一笑,指向了下一章的传送门:“听说下一回,有一种神奇的兵器,叫做 InlineArray,专门治愈你的‘性能强迫症’。”

在这里插入图片描述

(欲知后事如何,且看下回分解:InlineArray —— 当元组和数组生了个混血儿,熊猫的竹笋终于有地儿放了。)

在这里插入图片描述

解决移动端键盘遮挡痛点

作者 MoMoDad
2025年12月29日 16:54

在移动端开发中,「输入框被虚拟键盘遮挡」是一个高频且影响用户体验的痛点——当用户点击底部输入框时,系统弹出的键盘会占据屏幕下半部分,导致目标元素被遮挡,用户无法确认输入内容,甚至找不到提交按钮。今天我们就来深度拆解一段专门解决该问题的函数 scrollToElementAboveKeyboard,搞懂它的实现逻辑、核心亮点及优化方向。

一、函数核心功能概述

该函数的核心目标的是:在移动端场景下,当虚拟键盘弹出时,自动将指定 DOM 元素(通常是输入框、提交按钮等交互元素)滚动到可视区域内,避免被键盘遮挡,同时保证滚动过程的丝滑体验。它支持两种滚动方案,适配不同兼容性场景,且包含完善的容错处理,是移动端表单交互的实用工具函数。

二、完整代码与逐行解析

先贴出完整代码,再分模块拆解逻辑:

scrollToElementAboveKeyboard(elementId) {
    const target = document.getElementById(elementId);
    if (!target) {
        console.error(`未找到ID为${elementId}的元素`);
        return;
    }    
    // 获取元素的视口位置信息(距离顶部、底部、高度等)
    const rect = target.getBoundingClientRect();
    // 键盘弹出时,可视区域高度会变小,这里取当前可视区域高度
    const viewportHeight = window.innerHeight; 
    // 计算元素底部是否超出可视区域(被键盘遮挡)
    if (rect.bottom > viewportHeight) {
        // 方案1:推荐!使用scrollIntoView,自动适配滚动
        target.scrollIntoView({
            behavior: 'smooth', // 平滑滚动
            block: 'center'     // 元素居中显示(也可设为'top'置顶)
        });    
        // 方案2:手动计算滚动距离(兼容特殊场景)
        // const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
        // const needScroll = scrollTop + rect.bottom - viewportHeight + 20; // +20是预留间距
        // window.scrollTo({
        //     top: scrollTop + needScroll,
        //     behavior: 'smooth'
        // });
    }
}

1. 函数入口与容错处理

函数接收一个参数 elementId,即需要滚动到可视区域的目标元素 ID。首先通过 document.getElementById(elementId) 查找目标元素,若元素不存在,则打印错误日志并直接返回,避免后续代码因空值报错,这是工业级代码必备的容错机制。

const target = document.getElementById(elementId);
if (!target) {
    console.error(`未找到ID为${elementId}的元素`);
    return;
}

2. 位置与视口信息获取

这一步是判断元素是否被遮挡、计算滚动距离的核心:

  • target.getBoundingClientRect():获取元素相对于当前可视区域的位置信息,返回一个包含 topbottomleftright 等属性的对象。其中 rect.bottom 表示元素底部到可视区域顶部的距离,是判断元素是否被遮挡的关键指标。
  • window.innerHeight:获取当前可视区域的高度。移动端虚拟键盘弹出时,会挤压可视区域空间,导致该值显著减小,这也是间接感知键盘弹出的一种方式。

代码中注释掉的 uni.showToast 是调试用代码,可用于直观查看 rect.bottomviewportHeight 的值,方便定位问题。

3. 核心滚动逻辑(两种方案)

函数提供了两种滚动方案,适配不同场景需求,其中方案 1 为推荐方案。

方案 1:使用 scrollIntoView 自动滚动(推荐)

这是浏览器原生 API,无需手动计算滚动距离,兼容性好且逻辑简洁:

target.scrollIntoView({
    behavior: 'smooth', // 平滑滚动,提升用户体验
    block: 'center'     // 元素最终在视口垂直居中显示
});

关键参数说明:

  • behavior: 'smooth':设置滚动为平滑过渡,避免瞬间跳转带来的突兀感,优化用户体验;若需快速滚动,可设为'auto'(默认值,瞬间跳转)。
  • block: 'center':控制元素在视口中的垂直对齐方式,可选值为 'center'(居中)、'top'(置顶)、'bottom'(置底)。推荐设为 'center',可避免元素置顶后仍被键盘边缘遮挡的情况。

方案 2:手动计算滚动距离(兼容特殊场景)

该方案通过手动计算需要滚动的距离,适合 scrollIntoView 表现不一致的特殊场景(如部分老旧移动端浏览器、嵌套滚动容器场景):

const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
const needScroll = scrollTop + rect.bottom - viewportHeight + 20; // +20是预留间距
window.scrollTo({
    top: scrollTop + needScroll,
    behavior: 'smooth'
});

逻辑解析:

  • scrollTop:获取当前页面滚动条距离顶部的距离,兼容不同浏览器写法。
  • needScroll:计算需要额外滚动的距离,其中 +20 是预留间距,避免元素底部紧贴键盘边缘,提升交互舒适度。
  • window.scrollTo:控制页面滚动到指定位置,配合behavior: 'smooth' 实现平滑滚动。

4. 注释掉的遮挡判断逻辑

代码中 if (rect.bottom > viewportHeight)被注释掉,该逻辑的作用是:仅当元素底部超出可视区域(即被键盘遮挡)时,才执行滚动操作

当前注释状态下,调用函数就会触发滚动,无论元素是否被遮挡。实际开发中建议恢复该判断,避免不必要的滚动操作,进一步优化性能和用户体验。

三、使用场景与优化建议

1. 典型使用场景

该函数适用于所有移动端表单交互场景,例如:

  • 登录/注册页面的输入框(用户名、密码、验证码);
  • 聊天页面的底部输入框;
  • 表单提交页的底部提交按钮。

调用示例(在输入框聚焦时触发):

// 输入框聚焦事件
document.getElementById('username').addEventListener('focus', () => {
    scrollToElementAboveKeyboard('username');
});

2. 优化建议

  • 恢复遮挡判断逻辑:将 if (rect.bottom > viewportHeight) 注释解除,仅在元素被遮挡时滚动,减少无效操作。
  • 增加参数可配置化:允许传入滚动对齐方式(block)、滚动行为(behavior)、预留间距等参数,提升函数通用性。
  • 兼容嵌套滚动容器:若目标元素在嵌套滚动容器(如 div[scrollable])中,需调整滚动逻辑,针对容器而非页面进行滚动(将 window.scrollTo 改为容器的滚动方法)。
  • 延迟执行滚动:部分移动端浏览器键盘弹出有延迟,可通过 setTimeout 延迟 100-200ms 执行滚动,避免滚动时机过早导致效果失效。

四、总结

「scrollToElementAboveKeyboard」函数通过简洁的逻辑解决了移动端键盘遮挡的核心痛点,核心优势在于:

  • 包含完善的容错处理,代码健壮性强;
  • 提供两种滚动方案,适配不同兼容性场景;
  • 支持平滑滚动,兼顾功能与用户体验。

实际开发中,可根据项目的浏览器兼容性、滚动容器结构,对函数进行针对性优化,让移动端表单交互更流畅。

Swift——高阶函数(map、filter、reduce、forEach、sorted、contains……)

作者 Haha_bj
2025年12月25日 18:52

本文主要讲解 map、filter、reduce、forEach、sorted、contains 、 first(where:) / last(where:) 、firstIndex 和 lastIndex 、prefix( :) 和 dropFirst( :) 、 allSatisfy(_:) 、 lazy:延迟加载

一、map

map 函数,Swift 中最常用的高阶函数之一,核心作用是将集合中的每个元素按照指定规则转换,返回一个新的同类型集合,非常适合批量处理数组、字典等集合类型的元素。 map 就像一个 “转换器”:遍历集合中的每一个元素,把每个元素传入你定义的转换规则(闭包),然后将转换后的结果收集起来,形成一个新的集合返回。

  • 原集合不会被修改(纯函数特性)
  • 新集合的元素数量和原集合完全一致
  • 新集合的元素类型可以和原集合不同
    let prices = [100,200,300]
    let discountedPrices = prices.map{$0 * 10}
    print(discountedPrices) // [1000, 2000, 3000]
    let cast = ["Vivien", "Marlon", "Kim", "Karl"]
    let lowercaseNames = cast.map{$0.lowercased()}
    print(lowercaseNames) // ["vivien", "marlon", "kim", "karl"]
    let letterCounts = cast.map{$0.count}
    print(letterCounts)//  [6, 6, 3, 4]

二、 filter

filter 函数和 map 并列的核心高阶函数,filter的核心作用是根据指定条件筛选集合中的元素,返回符合条件的新集合,非常适合从数组、字典等集合中 “挑选” 需要的元素。 filter 就像一个 “筛选器”:遍历集合中的每一个元素,把每个元素传入你定义的判断条件(闭包),只有满足条件(闭包返回 true)的元素会被保留,最终返回一个包含所有符合条件元素的新集合。

  • 原集合不会被修改
  • 新集合的元素数量 ≤ 原集合
  • 新集合的元素类型和原集合完全一致
    // 示例1:筛选数字数组中的偶数
    let numbers = [1, 2, 3, 4, 5, 6, 7, 8]
    let evenNumbers = numbers.filter { number in
        return number % 2 == 0
    }
    print(evenNumbers) // 输出:[2, 4, 6, 8]
    // 简化写法
    let evenNumbersShort = numbers.filter { $0 % 2 == 0 }
    print(evenNumbersShort) // 输出:[2, 4, 6, 8]

    // 示例2:筛选字符串数组中长度大于5的元素
    let fruits = ["apple", "banana", "orange", "grape", "watermelon"]
    let longFruits = fruits.filter { $0.count > 5 }
    print(longFruits) // 输出:["banana", "orange", "watermelon"]
 // 示例3:筛选自定义对象数组(比如筛选年龄≥18的用户)
    struct User {
        let name: String
        let age: Int
    }
    let users = [
        User(name: "张三", age: 17),
        User(name: "李四", age: 20),
        User(name: "王五", age: 25)
    ]
    let adultUsers = users.filter { $0.age >= 18 }
    print(adultUsers.map { $0.name }) // 输出:["李四", "王五"]

三、reduce

reduce 核心作用是将集合中的所有元素 “归约”/“汇总” 成一个单一的值(比如求和、拼接字符串、计算总宽度、生成字典等),可以理解为把一组元素 “压缩” 成一个结果。

reduce 就像一个 “汇总器”:从一个初始值开始,遍历集合中的每一个元素,将当前元素与累计结果做指定运算,最终得到一个单一的汇总值。

  • 原集合不会被修改
  • 最终结果的类型可以和集合元素类型不同(比如数组元素是 Int,结果可以是 String;元素是 CGFloat,结果可以是 CGFloat
  • 核心逻辑:初始值 + 元素1 → 累计值1 + 元素2 → 累计值2 + ... → 最终结果
    // 示例1:数字数组求和(最基础用法)
    let numbers = [1, 2, 3, 4, 5]
    // 初始值为0,累计规则:累计值 + 当前元素
    let sum = numbers.reduce(0) { partialSum, number in
        return partialSum + number
    }
    // 简化写法
    let sumShort = numbers.reduce(0, +) // 直接用运算符简写,等价于上面的闭包
    print(sum) // 输出:15

    // 示例2:字符串数组合并成一个完整字符串
    let words = ["Hello", " ", "Swift", " ", "reduce!"]
    // 初始值为空字符串,累计规则:拼接字符串
    let sentence = words.reduce("") { $0 + $1 }
    print(sentence) // 输出:"Hello Swift reduce!"

    // 示例3:计算数组中最大值(初始值设为最小值)
    let scores = [85, 92, 78, 95, 88]
    let maxScore = scores.reduce(Int.min) { max($0, $1) }
    print(maxScore) // 输出:95

四、 forEach

forEach 函数,它是集合的基础遍历方法,核心作用是遍历集合中的每一个元素并执行指定操作,和传统的 for-in 循环功能类似,但写法更简洁,且是函数式编程风格的遍历方式。

forEach 就像一个 “遍历执行器”:按顺序遍历集合中的每一个元素,对每个元素执行你定义的闭包操作(比如打印、修改属性、调用方法等)。

  • 原集合不会被修改(除非你在闭包内主动修改元素的可变属性)
  • 没有返回值(Void),这是和 map/filter/reduce 最大的区别(后三者都返回新集合 / 值)
  • 无法用 break/continue 中断 / 跳过遍历(如需中断,建议用传统 for-in 循环)
// 示例1:遍历打印数组元素
let fruits = ["apple", "banana", "orange"]
fruits.forEach { fruit in
    print("水果:\(fruit)")
}
// 简化写法
fruits.forEach { print("水果:\($0)") }
   let numbers = [1, 2, 3, 4, 5]

    // 需求:遍历到3时停止
    // ❌ forEach 无法中断,会遍历所有元素
    numbers.forEach {
        if $0 == 3 {
            return // 仅跳过当前元素,不会中断整体遍历
        }
        print($0) // 输出:1,2,4,5
    }

    // ✅ for-in 可以中断
    for number in numbers {
        if number == 3 {
            break // 直接中断遍历
        }
        print(number) // 输出:1,2
    }

五、 sorted 排序

sorted 函数,它是集合中用于排序的核心高阶函数,核心作用是将集合中的元素按指定规则排序,返回一个新的有序集合(原集合保持不变)。

 // 示例1:数字数组默认排序(升序)
    let numbers = [5, 2, 9, 1, 7]
    let sortedNumbers = numbers.sorted()
    print(sortedNumbers) // 输出:[1, 2, 5, 7, 9]

    // 示例2:自定义降序排序
    let descendingNumbers = numbers.sorted { $0 > $1 }
    print(descendingNumbers) // 输出:[9, 7, 5, 2, 1]

    // 示例3:字符串数组排序(默认字母序,区分大小写)
    let fruits = ["banana", "Apple", "orange", "grape"]
    let sortedFruits = fruits.sorted()
    print(sortedFruits) // 输出:["Apple", "banana", "grape", "orange"]

    // 示例4:字符串忽略大小写排序(自定义规则)
    let caseInsensitiveFruits = fruits.sorted { $0.lowercased() < $1.lowercased() }
    print(caseInsensitiveFruits) // 输出:["Apple", "banana", "grape", "orange"](和上面结果一样,但逻辑更通用)

六、contains

contains 函数,它是集合用于判断 “是否包含指定元素 / 符合条件的元素” 的核心方法,核心作用是快速检查集合中是否存在目标元素或满足条件的元素,返回布尔值(true/false)。 contains 就像一个 “检测器”:遍历集合并检查是否存在符合要求的元素,无需手动遍历判断,代码更简洁。

  • 有两个常用变体:
    1. contains(_:):检查是否包含具体某个元素(要求元素遵循 Equatable 协议,如 Int、String、CGSize 等默认遵循)
    2. contains(where:):检查是否包含符合自定义条件的元素(更灵活,适用于复杂判断)
  • 返回值是 Booltrue= 包含,false= 不包含)
  • 原集合不会被修改
    // 示例1:检查是否包含具体数字
    let numbers = [1, 2, 3, 4, 5]
    let hasThree = numbers.contains(3)
    let hasTen = numbers.contains(10)
    print(hasThree) // 输出:true
    print(hasTen) // 输出:false

    // 示例2:检查是否包含具体字符串
    let fruits = ["apple", "banana", "orange"]
    let hasBanana = fruits.contains("banana")
    print(hasBanana) // 输出:true

    // 示例3:检查是否包含符合条件的元素(数字大于3)
    let hasGreaterThanThree = numbers.contains { $0 > 3 }
    print(hasGreaterThanThree) // 输出:true(4、5都满足)

    // 示例4:检查是否包含长度大于5的字符串
    let hasLongFruit = fruits.contains { $0.count > 5 }
    print(hasLongFruit) // 输出:true(banana、orange长度都大于5)

七、 first(where:) 和 last(where:)

first(where:) 和 last(where:) 方法,它们是集合中用于精准查找第一个 / 最后一个符合条件元素的核心方法,返回值是可选类型(T?)—— 找到则返回对应元素,找不到则返回 nil

first(where:) / last(where:) 就像 “精准查找器”:

  • first(where:)从前往后遍历集合,返回第一个满足条件的元素(可选值)
  • last(where:)从后往前遍历集合,返回最后一个满足条件的元素(可选值)
  • 两者都不会修改原集合,且找到目标元素后会立即停止遍历(性能优于先 filter 再取 first/last
  • 若集合为空或无符合条件的元素,返回 nil
    // 示例1:查找第一个大于3的数字
    let numbers = [1, 2, 3, 4, 5, 4, 3]
    let firstGreaterThan3 = numbers.first { $0 > 3 }
    print(firstGreaterThan3) // 输出:Optional(4)(第一个满足的是索引3的4)

    // 示例2:查找最后一个大于3的数字
    let lastGreaterThan3 = numbers.last { $0 > 3 }
    print(lastGreaterThan3) // 输出:Optional(4)(最后一个满足的是索引5的4)

    // 示例3:查找第一个长度大于5的字符串
    let fruits = ["apple", "banana", "orange", "grape"]
    let firstLongFruit = fruits.first { $0.count > 5 }
    print(firstLongFruit) // 输出:Optional("banana")

    // 示例4:无符合条件元素时返回nil
    let firstGreaterThan10 = numbers.first { $0 > 10 }
    print(firstGreaterThan10) // 输出:nil

八、 firstIndex 和 lastIndex

firstIndex(of:) / firstIndex(where:) 和 lastIndex(of:) / lastIndex(where:) 方法,它们是集合中用于查找元素对应索引的核心方法,返回值为可选类型的 Index(通常是 Int 类型)—— 找到则返回元素的索引,找不到则返回 nil

方法 作用 适用条件
firstIndex(of:) 从前往后找第一个匹配指定元素的索引 元素遵循 Equatable 协议
firstIndex(where:) 从前往后找第一个符合自定义条件的元素的索引 无(更灵活,支持复杂判断)
lastIndex(of:) 从后往前找最后一个匹配指定元素的索引 元素遵循 Equatable 协议
lastIndex(where:) 从后往前找最后一个符合自定义条件的元素的索引
  • 所有方法返回值都是 Index?(数组中等价于 Int?),找不到则返回 nil
  • 找到目标后立即停止遍历,性能高效
  • 原集合不会被修改
 // 基础数组
    let numbers = [1, 2, 3, 2, 5, 2]
    let fruits = ["apple", "banana", "orange", "banana"]

    // 示例1:firstIndex(of:) —— 找第一个2的索引
    if let firstTwoIdx = numbers.firstIndex(of: 2) {
        print("第一个2的索引:\(firstTwoIdx)") // 输出:1
    }

    // 示例2:lastIndex(of:) —— 找最后一个2的索引
    if let lastTwoIdx = numbers.lastIndex(of: 2) {
        print("最后一个2的索引:\(lastTwoIdx)") // 输出:5
    }

    // 示例3:firstIndex(where:) —— 找第一个大于3的数字的索引
    if let firstGreater3Idx = numbers.firstIndex { $0 > 3 } {
        print("第一个大于3的数字索引:\(firstGreater3Idx)") // 输出:4(数字5)
    }

    // 示例4:lastIndex(where:) —— 找最后一个"banana"的索引
    if let lastBananaIdx = fruits.lastIndex { $0 == "banana" } {
        print("最后一个banana的索引:\(lastBananaIdx)") // 输出:3
    }

    // 示例5:无匹配元素时返回nil
    if let noExistIdx = numbers.firstIndex(of: 10) {
        print(noExistIdx)
    } else {
        print("未找到元素10") // 输出:未找到元素10
    }
        

九、prefix( :) 和 dropFirst( :)

prefix(:) 和 dropFirst(:) 方法,它们是集合中用于截取 / 剔除前 N 个元素的核心方法,返回新的集合片段(PrefixSequence/DropFirstSequence,可直接转为数组),原集合保持不变。

方法 核心作用 返回值类型 原集合影响
prefix(_:) 截取集合前 n 个元素(若 n 超过集合长度,返回全部元素) PrefixSequence<T>
dropFirst(_:) 剔除集合前 n 个元素,返回剩余元素(若 n 超过集合长度,返回空集合) DropFirstSequence<T>
  • 补充:还有无参数简化版 prefix()(等价于 prefix(1),取第一个元素)、dropFirst()(等价于 dropFirst(1),剔除第一个元素);
  • 返回的 Sequence 可通过 Array() 转为普通数组,方便后续操作。
// 基础数组
let numbers = [1, 2, 3, 4, 5]
let fruits = ["apple", "banana", "orange", "grape"]

// 示例1:prefix(_:) —— 截取前3个元素
let prefix3Numbers = Array(numbers.prefix(3))
print(prefix3Numbers) // 输出:[1, 2, 3]

// 示例2:prefix(_:) —— n 超过数组长度,返回全部
let prefix10Numbers = Array(numbers.prefix(10))
print(prefix10Numbers) // 输出:[1, 2, 3, 4, 5]

// 示例3:dropFirst(_:) —— 剔除前2个元素
let drop2Numbers = Array(numbers.dropFirst(2))
print(drop2Numbers) // 输出:[3, 4, 5]

// 示例4:dropFirst(_:) —— n 超过数组长度,返回空
let drop10Numbers = Array(numbers.dropFirst(10))
print(drop10Numbers) // 输出:[]

// 示例5:无参数版
let firstFruit = Array(fruits.prefix(1))      // 等价于 prefix(1)
let restFruits = Array(fruits.dropFirst())   // 等价于 dropFirst(1)
print(firstFruit)  // 输出:["apple"]
print(restFruits)  // 输出:["banana", "orange", "grape"]

九、 allSatisfy(_:)

allSatisfy 方法,它是集合中用于判断所有元素是否都满足指定条件的核心方法,返回布尔值(true/false)—— 只有当集合中每一个元素都符合条件时返回 true,只要有一个不符合就返回 false

allSatisfy 就像一个 “全量校验器”:

  • 遍历集合中的每一个元素,依次检查是否符合条件;
  • 只要发现一个元素不符合条件,会立即停止遍历(性能高效),返回 false
  • 只有所有元素都符合条件,才会遍历完成并返回 true
  • 空集合调用 allSatisfy 会直接返回 true(逻辑上 “空集合中所有元素都满足条件”);
  • 原集合不会被修改。
// 基础数组
let numbers = [2, 4, 6, 8]
let mixedNumbers = [2, 4, 7, 8]
let fruits = ["apple", "banana", "orange"]

// 示例1:检查所有数字是否都是偶数
let allEven = numbers.allSatisfy { $0 % 2 == 0 }
print(allEven) // 输出:true

let mixedEven = mixedNumbers.allSatisfy { $0 % 2 == 0 }
print(mixedEven) // 输出:false(7是奇数,遍历到7时立即返回false)

// 示例2:检查所有字符串长度是否大于3
let allLongerThan3 = fruits.allSatisfy { $0.count > 3 }
print(allLongerThan3) // 输出:true(apple=5, banana=6, orange=6)

// 示例3:空集合调用返回true
let emptyArray: [Int] = []
let emptyAllMatch = emptyArray.allSatisfy { $0 > 10 }
print(emptyAllMatch) // 输出:true

十、 lazy:延迟加载

let hugeRange = 1...1000000
let result = hugeRange.lazy
    .filter { $0 % 3 == 0 }
    .map { $0 * 2 }
    .prefix(10)

lazy会延迟计算,直到真正需要结果时才执行操作,避免创建大量中间数组。

我调教了 50 次 AI,就为了能点开这片记录了 2025 年的雪花

作者 Selina
2025年12月25日 23:34

岁末年初,朋友圈又开始了年度报告的大赛。各个平台都拿出了各种设计、交互、数据,势必要占领你的朋友圈。

拜托,现在 AI 已经这么好用了,为什么不能自己做一个呢?尤其是这一年,有大量的时间正是花在这些 AI 工具里。

没想到,这一个小小的念头,引发了一场我在 AI studio 里埋头苦干了两天,先后完成了两个版本:一个是基于静态和简单互动的「传统版」。

另一个是具备动态效果、可无限缩放、结合 3D 粒子和互动的「技能版」。

更没想到的是,整个经过改变了以往我对与 AI 协作互助的理解:万能咒语什么的不存在的,真正的魔法武器只有一个。

自己做一个技能版「年终总结」

在开始之前,先准备好你最常用的 AI chatbot——一定要是最常用的,几乎每天都要聊个两句的那种。数据不够不仅做不了有意思的总结,还可能被硬塞不存在的数据。

我准备的是 ChatGPT,直接起一个新窗口,输入以下 prompt:

请基于这一年的对话内容,从“数量、主题、时间、情绪、使用习惯、人格特征”等维度,构建数据感的总结,包含模拟数据以及 ASCII 图表,请严格按以下结构生成:
【1. 年度总览】
今年与 GPT 的总互动次数
发送消息总字数 + 接收消息总字数
最常互动的时段
最长连续对话时长
【2. 互动类型分布(饼图)】
请用 ASCII 图展示:
情感类、讨论类、创作类、学习类、角色扮演类、其他类
【3. 高频主题排行(TOP 10)】
以排行榜形式展示,并给每个主题一句点评。
【4. 我的年度情绪轨迹(线形图)】
模拟分析我在对话中的情绪曲线
【5. 用户行为画像(雷达图)】
雷达图维度包括:
好奇心、依赖度、分析深度、 表达欲、情绪敏感度、 自我剖析频率
【6. 使用时段与频率(柱状图)】
柱状图展示我全年最常用来找 GPT 的时间段: 凌晨、上午、下午、晚上、深夜
【7. 我的互动习惯标签】
请根据全年模式,为我生成 6-10 个类似“APP 年度画像”的标签。并设计 6 个带名称的年度成就徽章
【8. AI 眼中的我(数据 + 叙事结合)】
结合年度模式,写一段带数据隐喻的:
“我是怎样的人,我的灵魂像什么,我为什么值得这样的总结。”
【9. 年度一句话总结】

总体风格要求:
数据可视化 + 年度回顾混合风, 图表使用 ASCII,可视化要清晰、好看、易读,文案具有科技感、沉浸感、叙事感,避免大众化套话。

这些就是接下来的基础素材了,在上述这种 prompt 的指令下,GPT 只会输出纯文本,图也是草草画一画。所以接下来要转移到 Gemini/AI Studio 上去做进一步的排版。

AI Studio 依然是最推荐的地方,除了可以选择更多模型、互动过程更直观,还有一个更重要的原因后面讲。

年终总结里,数据只是素材,更重要的是排版——这一项已经卷出花来了,充分地进入了 AI 的数据库,用几行基础 prompt 就可以实现。

帮我以可交互式 H5 的形式,制作一个年终总结页面。总结文案我将会在下面给出,形式要求:1. 可交互式,交付可本地打开的 html 网页 2. 根据文案内容拆分版块,在需要使用图表的部分制作图表 3. 版式要求:文字使用衬线体,背景色彩可以自主调节。总结文案如下:(补充你的文案)

很多人抱怨, AI 生成的视觉图表有一股廉价的「塑料感」,效果不坏,但也说不上好——这就是基础 prompt 的缺点。所以,在制作报告时我直接放弃了使用「大气、高级」这类模糊的形容词。AI 听不懂这些,它只能精准执行参数,拆分成一步步会更加有效。

比如,为了达到最终那种深邃优雅的视觉效果,我将需求拆解成了具体的描述:背景为深紫色渐变与暗灰色的色块晕染,晕染效果随机变化——具体的颜色、形态,而非空洞的叙述「大气」「高级」,AI 弄不明白的。

类似的,微调图表时,也要尽可能的具体:雷达图需要呈现出磨砂玻璃般的半透明质感。

加入交互时,描述你想要实现的效果——尽可能地细致,比如:将「年度十大主题」按照十宫格排列,点击来使每一个格子反转,文案始终置于居中位置。

这种调校的过程,本质上是在用你的审美,去 battle AI 的执行效率。不过,现在 Gemini 的审美远比我想象的要好,比如我提了一个多出几个配色的要求,它给出的三种配色都还不错。

隐藏武器:「回滚」

做传统的年终总结,整体过程比较像和设计师合作,这里改改颜色、哪里换个版式。但「技能年终总结」,就是和工程师合作了。

在重新研究了一遍 GPT 给出的文字总结后,我第一时间想到的是和网上流行的圣诞树做结合。

▲ 图片来自:小红书用户 @黑波

但是在对比之后,发现年终总结高度格式化的章节、数字,并不适合用圣诞树这样的形式去呈现。所以我先是参考圣诞树的设计 prompt,但把主体改为了结构更清晰的雪花结晶。prompt 如下:

角色设定:你是一位精通 React 19、TypeScript 和 Three.js (R3F) 的 3D 创意开发专家。 任务目标: 构建一个名为“圣诞雪花”的高保真 3D Web 应用。视觉风格主色调为深祖母绿和高光金色,并伴有电影级的辉光效果。 技术栈: React 19, TypeScript, React Three Fiber, Drei, Postprocessing, Tailwind CSS。

雪花结晶体的结构可以更清晰的展示出节点,这样,就可以用红宝石不同的年度总结板块。点击时,散落在夜空中的粒子和红宝石,共同组成了一朵雪花——就像一个个重要的事件、习惯、统计数字,构成了这一整年。

然后就是漫长……漫长……漫长……的修改流程。在我的预想里,每一个红宝石封装了一部分内容,一次性把完整的总结文案喂进去是行不通的。这也是很多人写 prompt 时的「毛病」,喜欢一下子把所有需求堆上去,结果 AI 给的代码往往漏洞百出。这边给到的一个建议是: 先定骨架,再调动作。比如一个雪花的动效,我分了三步:

第一步: 先让它把雪花的 3D 形状写出来,只要形状对了,先下载一个版本,你可以在这里找到下载按钮。

第二步: 让它加上自转和红宝石节点,不急着塞内容,只是把几个节点改成红宝石的形状。

第三步: 最后才去磨那个点击缩放的逻辑,放大时是什么效果、要不要加返回键……

每一步只要达成预期,就别乱动。一旦发现没有效果,让 Gemini 自行 debug 也无效的话,启动武器:

这是我做这个项目时最重大的发现:回滚。功能越复杂,需求越多,AI 越容易出错。完成一个新需求的时候,无法避免要「重新生成」一些东西,所以整个代码的其它地方本来是没问题的,改完却出现新的 bug。

结果就是,越在错误的代码上缝缝补补,加的补丁越多,bug 就出现得越多。所谓「按下葫芦浮起瓢」,是最劝退的一步。

所以,效率最高的做法是,当你发现 AI 为了加一个新功能(比如换个颜色),把之前已经调好的交互逻辑给「洗」掉时,不必执着于在对话框里跟它吵架,让它「改回来」。最快的方法是直接回退到上一个版本,再输入新指令——记住,你是指挥官,它是执行者,AI 乱了,你要把它拉回到正确的轨道上。

 

这对零码选手尤其有意义,作为一个很少去翻看冗长代码、只看预览效果的普通用户,这就是最简单粗暴的「咒语」:别去纠结它哪行代码写错了,直接回滚。

这个项目里我的整个工程一度崩溃过:中间我提出,「优化一下红宝石的材质,让它看起来更透亮」,看代码预览 Gemini 是在跑,但是回到预览页却没有一丝变化。

一运行,材质没有大变化,点击缩放的功能还给废了。AI 在重写材质代码时,顺手把我调了一下午的点击交互给抹掉了。这种时候,在对话框里跟它大发雷霆其实没有用,提出「缩放功能没有了补回去」,也很容易卡死,AI 会一边道歉一边给你补一个更烂的 Bug。

与其纠结,不如一键 restore,回滚到那个「材质虽丑但交互正常」的版本,这种对预览效果的「死守」,比任何高级 prompt 都管用。

不过要注意的是,回滚只有上一个版本,更远一点的版本是不支持的。可以把它理解为「退回到上一步」,类似 Ctrl+Z 这样的操作。

到了后面,我的想法越来越被耗尽,所幸让 Gemini 自主完成一些设计工作。在整体视觉已经完全确定的情况下,它的发挥其实还不错。比如这个年度成就徽章「英灵殿」,就是完全由它设计的。

鼠标悬停即展示具体的成就名称,也是 Gemini 想出来的主意。另一张统计里,它还自己画上了心跳图。

最后一颗宝石里装载的是「一句话」总结,Gemini 把最后这颗宝石改成了白色的锥型晶体,跟其它的红宝石区别开来。

在制作这篇年终总结时,我被问到最多的问题是:「Prompt 是什么?」

也不意外,AI 用到现在,这已经成了大家下意识就要问的问题。但是说句掏心窝子的话,真的没有什么一键成型的魔法咒语。

每个人的 2025 都是独一无二的,每个人想要通过 AI 记录的转折点、战绩和情绪也都不一样。你喜欢一棵挂满礼物的圣诞树,而我喜欢这片在星空中转动的雪花。每个人都有自己的审美偏好,而 AI 最大的魅力,绝不是让你能复制出一份和我一模一样的报告。

相反,AI 最大的意义是:它第一次抹平了「想得到」与「做出来」之间的鸿沟。 以前你受限于不会代码、不会设计,只能接受千篇一律的模板;而现在,只要你愿意花点时间去跟它「死磕」,去描述你脑海中那个具体的画面,AI 就能帮你把那个只属于你的世界折叠出来。

Prompt 是冷的,但你的记忆和审美是有温度的。

如果非要总结出一个公式,那可能就是:一点点想象力 + 几十次耐心回退 + 绝不向平庸效果妥协的审美。

别再到处找「万能指令」了。新的一年,试着去跟 AI 聊聊天,去「嫌弃」它的平庸,去坚持你的直觉。你会发现,正如同每一年里不停止的自我更新和挑战,对这一年最好的总结,恰恰就是你不断推倒重来的过程本身。

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

爱范儿 | 原文链接 · 查看评论 · 新浪微博


iOS Objective-C 协议一致性检查:从基础到优化的完整解决方案

作者 图图大恼
2025年12月24日 21:57

概述

在 Objective-C 开发中,协议(Protocol)是实现接口抽象和多态的重要机制。然而,编译器对协议实现的检查有时存在局限性,特别是在动态运行时和复杂的继承关系中。本文将介绍一个完整的协议一致性检查解决方案,涵盖基础实现、功能扩展。

完整代码

// ProtocolConformanceChecker.h
#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

@interface ProtocolConformanceChecker : NSObject

/**
 验证对象是否完整实现了指定协议

 @param objc 要验证的对象
 @param protocol 要验证的协议
 @param checkOptionalMethods 是否检查可选方法
 @param checkClassMethods 是否检查类方法
 */
+ (void)assertObjC:(id)objc
  conformsToProtocol:(Protocol *)protocol
checkOptionalMethods:(BOOL)checkOptionalMethods
  checkClassMethods:(BOOL)checkClassMethods;

@end

NS_ASSUME_NONNULL_END

// ProtocolConformanceChecker.m
#import "ProtocolConformanceChecker.h"
#import <objc/runtime.h>

@interface _ProtocolMethodInfo : NSObject
@property (nonatomic, copy) NSString *methodName;
@property (nonatomic, copy) NSString *typeEncoding;
@property (nonatomic, assign) BOOL isRequired;
@property (nonatomic, assign) BOOL isInstanceMethod;
@end

@implementation _ProtocolMethodInfo
@end

@implementation ProtocolConformanceChecker

#pragma mark - 主验证方法

+ (void)assertObjC:(id)objc
  conformsToProtocol:(Protocol *)protocol
checkOptionalMethods:(BOOL)checkOptionalMethods
  checkClassMethods:(BOOL)checkClassMethods {

    // 1. 获取所有需要检查的方法
    NSArray<_ProtocolMethodInfo *> *allMethods =
        [self getAllMethodsForProtocol:protocol
                  checkOptionalMethods:checkOptionalMethods
                    checkClassMethods:checkClassMethods];

    // 2. 验证每个方法的实现
    NSMutableArray<NSString *> *unconformsMethods = [NSMutableArray array];

    for (_ProtocolMethodInfo *methodInfo in allMethods) {
        if (![self object:objc implementsMethod:methodInfo]) {
            NSString *methodDesc = [self formatMethodDescription:methodInfo];
            [unconformsMethods addObject:methodDesc];
        }
    }

    // 3. 报告验证结果
    [self reportValidationResultForObject:objc
                      unconformsMethods:unconformsMethods];
}

#pragma mark - 私有辅助方法

+ (NSArray<_ProtocolMethodInfo *> *)getAllMethodsForProtocol:(Protocol *)protocol
                                        checkOptionalMethods:(BOOL)checkOptionalMethods
                                          checkClassMethods:(BOOL)checkClassMethods {

    NSMutableArray<_ProtocolMethodInfo *> *allMethods = [NSMutableArray array];

    // 获取必需方法
    [allMethods addObjectsFromArray:
        [self getMethodsForProtocol:protocol
                         isRequired:YES
                  checkClassMethods:checkClassMethods]];

    // 获取可选方法(如果需要)
    if (checkOptionalMethods) {
        [allMethods addObjectsFromArray:
            [self getMethodsForProtocol:protocol
                             isRequired:NO
                      checkClassMethods:checkClassMethods]];
    }

    return [allMethods copy];
}

+ (NSArray<_ProtocolMethodInfo *> *)getMethodsForProtocol:(Protocol *)protocol
                                             isRequired:(BOOL)isRequired
                                      checkClassMethods:(BOOL)checkClassMethods {

    NSMutableArray<_ProtocolMethodInfo *> *methods = [NSMutableArray array];

    // 获取当前协议的方法
    [methods addObjectsFromArray:
        [self getMethodsForSingleProtocol:protocol
                               isRequired:isRequired
                        checkClassMethods:checkClassMethods]];

    // 递归获取继承协议的方法
    unsigned int protocolListCount;
    Protocol * __unsafe_unretained _Nonnull * _Nullable protocols =
        protocol_copyProtocolList(protocol, &protocolListCount);

    for (unsigned int i = 0; i < protocolListCount; i++) {
        [methods addObjectsFromArray:
            [self getMethodsForProtocol:protocols[i]
                             isRequired:isRequired
                      checkClassMethods:checkClassMethods]];
    }

    if (protocols) free(protocols);

    return [methods copy];
}

+ (NSArray<_ProtocolMethodInfo *> *)getMethodsForSingleProtocol:(Protocol *)protocol
                                                   isRequired:(BOOL)isRequired
                                            checkClassMethods:(BOOL)checkClassMethods {

    NSMutableArray<_ProtocolMethodInfo *> *methods = [NSMutableArray array];

    // 检查实例方法
    unsigned int instanceMethodCount;
    struct objc_method_description *instanceMethodDescriptions =
        protocol_copyMethodDescriptionList(protocol,
                                         isRequired,
                                         YES,  // 实例方法
                                         &instanceMethodCount);

    for (unsigned int i = 0; i < instanceMethodCount; i++) {
        _ProtocolMethodInfo *info = [_ProtocolMethodInfo new];
        info.methodName = NSStringFromSelector(instanceMethodDescriptions[i].name);
        info.typeEncoding = [NSString stringWithUTF8String:instanceMethodDescriptions[i].types];
        info.isRequired = isRequired;
        info.isInstanceMethod = YES;
        [methods addObject:info];
    }

    if (instanceMethodDescriptions) free(instanceMethodDescriptions);

    // 检查类方法(如果需要)
    if (checkClassMethods) {
        unsigned int classMethodCount;
        struct objc_method_description *classMethodDescriptions =
            protocol_copyMethodDescriptionList(protocol,
                                             isRequired,
                                             NO,  // 类方法
                                             &classMethodCount);

        for (unsigned int i = 0; i < classMethodCount; i++) {
            _ProtocolMethodInfo *info = [_ProtocolMethodInfo new];
            info.methodName = NSStringFromSelector(classMethodDescriptions[i].name);
            info.typeEncoding = [NSString stringWithUTF8String:classMethodDescriptions[i].types];
            info.isRequired = isRequired;
            info.isInstanceMethod = NO;
            [methods addObject:info];
        }

        if (classMethodDescriptions) free(classMethodDescriptions);
    }

    return [methods copy];
}

+ (BOOL)object:(id)objc implementsMethod:(_ProtocolMethodInfo *)methodInfo {
    if (methodInfo.isInstanceMethod) {
        // 检查实例方法
        Method method = class_getInstanceMethod([objc class],
                                              NSSelectorFromString(methodInfo.methodName));
        if (!method) return NO;

        // 检查方法签名是否匹配
        const char *typeEncoding = method_getTypeEncoding(method);
        return strcmp(typeEncoding, methodInfo.typeEncoding.UTF8String) == 0;
    } else {
        // 检查类方法
        Method method = class_getClassMethod([objc class],
                                           NSSelectorFromString(methodInfo.methodName));
        if (!method) return NO;

        // 检查方法签名是否匹配
        const char *typeEncoding = method_getTypeEncoding(method);
        return strcmp(typeEncoding, methodInfo.typeEncoding.UTF8String) == 0;
    }
}

+ (NSString *)formatMethodDescription:(_ProtocolMethodInfo *)methodInfo {
    NSString *methodType = methodInfo.isInstanceMethod ? @"实例方法" : @"类方法";
    NSString *requirement = methodInfo.isRequired ? @"必需" : @"可选";

    return [NSString stringWithFormat:@"%@ [%@, %@]",
            methodInfo.methodName,
            methodType,
            requirement];
}

+ (void)reportValidationResultForObject:(id)objc
                    unconformsMethods:(NSArray<NSString *> *)unconformsMethods {

    if (unconformsMethods.count == 0) {
        return; // 验证通过
    }

    NSString *errorMessage = [NSString stringWithFormat:
        @"%@ 未实现以下方法:\n%@",
        objc,
        [unconformsMethods componentsJoinedByString:@"\n"]];

    // 使用断言,在调试时中断执行
    NSAssert(NO, @"%@", errorMessage);

    // 生产环境记录日志
#ifdef RELEASE
    NSLog(@"Protocol Conformance Error: %@", errorMessage);
#endif
}

@end


流程图

mermaid-diagram.png

核心功能特性

1. 完整的协议继承链检查

系统采用递归算法遍历协议的所有父协议,确保检查完整的继承关系:

// 递归获取继承协议的方法
unsigned int protocolListCount;
Protocol **protocols = protocol_copyProtocolList(protocol, &protocolListCount);
for (unsigned int i = 0; i < protocolListCount; i++) {
    [self getMethodsForProtocol:protocols[i]
                     isRequired:isRequired
              checkClassMethods:checkClassMethods];
}

2. 灵活的方法检查配置

支持四种检查模式的任意组合:

// 使用示例 - 完整检查
[ProtocolConformanceChecker assertObjC:myObject
                    conformsToProtocol:@protocol(MyProtocol)
               checkOptionalMethods:YES   // 检查可选方法
                 checkClassMethods:YES];  // 检查类方法

// 使用示例 - 最小检查
[ProtocolConformanceChecker assertObjC:myObject
                    conformsToProtocol:@protocol(MyProtocol)
               checkOptionalMethods:NO    // 不检查可选方法
                 checkClassMethods:NO];   // 不检查类方法

3. 详细的方法签名验证

不仅检查方法是否存在,还验证方法签名(Type Encoding)是否完全匹配:

+ (BOOL)object:(id)objc implementsMethod:(_ProtocolMethodInfo *)methodInfo {
    const char *typeEncoding = method_getTypeEncoding(method);
    return strcmp(typeEncoding, methodInfo.typeEncoding.UTF8String) == 0;
}

实现细节解析

方法信息封装

使用轻量级的内部类封装方法信息,提高代码的可读性和可维护性:

@interface _ProtocolMethodInfo : NSObject
@property (nonatomic, copy) NSString *methodName;      // 方法名
@property (nonatomic, copy) NSString *typeEncoding;    // 类型编码
@property (nonatomic, assign) BOOL isRequired;         // 是否必需
@property (nonatomic, assign) BOOL isInstanceMethod;   // 是否为实例方法
@end

内存管理规范

严格遵守 Objective-C 运行时内存管理规范:

// 正确释放运行时分配的内存
if (instanceMethodDescriptions) free(instanceMethodDescriptions);
if (protocols) free(protocols);

清晰的错误报告

提供详细的错误信息,快速定位问题:

+ (NSString *)formatMethodDescription:(_ProtocolMethodInfo *)methodInfo {
    NSString *methodType = methodInfo.isInstanceMethod ? @"实例方法" : @"类方法";
    NSString *requirement = methodInfo.isRequired ? @"必需" : @"可选";

    return [NSString stringWithFormat:@"%@ [%@, %@]",
            methodInfo.methodName,
            methodType,
            requirement];
}

执行流程详解

步骤1:方法收集阶段

mermaid-diagram.png

步骤2:方法验证阶段

mermaid-diagram.png

步骤3:结果报告阶段

mermaid-diagram.png

使用场景示例

场景一:单元测试中的协议验证

// 验证 Mock 对象是否完整实现协议
- (void)testDataSourceProtocolConformance {
    // 创建 Mock 对象
    id mockDataSource = [OCMockObject mockForProtocol:@protocol(UITableViewDataSource)];

    // 验证协议实现
    [ProtocolConformanceChecker assertObjC:mockDataSource
                        conformsToProtocol:@protocol(UITableViewDataSource)
                   checkOptionalMethods:NO    // UITableViewDataSource 只有必需方法
                     checkClassMethods:NO];   // 数据源协议通常只有实例方法

    // 执行测试逻辑
    // ...
}

场景二:框架初始化验证

// 确保框架提供的基类正确实现协议
@implementation MyNetworkManager

+ (void)initialize {
    if (self == [MyNetworkManager class]) {
        // 验证类是否实现必要的协议
        [ProtocolConformanceChecker assertObjC:self
                            conformsToProtocol:@protocol(MyNetworkProtocol)
                       checkOptionalMethods:YES    // 检查所有可选方法
                         checkClassMethods:YES];   // 检查类方法
    }
}

@end

场景三:关键路径的防御性检查

// 在设置代理时进行验证
- (void)setDelegate:(id<MyCustomDelegate>)delegate {
    // 只在调试模式下进行完整验证
#ifdef DEBUG
    if (delegate) {
        [ProtocolConformanceChecker assertObjC:delegate
                            conformsToProtocol:@protocol(MyCustomDelegate)
                       checkOptionalMethods:YES    // 检查可选方法
                         checkClassMethods:NO];    // 代理协议通常只有实例方法
    }
#endif

    _delegate = delegate;
}

最佳实践

1. 调试与测试阶段

// 在单元测试中全面验证
- (void)testProtocolImplementation {
    [ProtocolConformanceChecker assertObjC:testObject
                        conformsToProtocol:@protocol(RequiredProtocol)
                   checkOptionalMethods:YES
                     checkClassMethods:YES];
}

2. 生产环境使用

// 使用条件编译控制检查行为
- (void)setupComponent:(id)component {
#ifdef DEBUG
    // 调试模式下进行全面检查
    [ProtocolConformanceChecker assertObjC:component                        conformsToProtocol:@protocol(ComponentProtocol)                   checkOptionalMethods:YES                     checkClassMethods:NO];
#else
    // 生产环境下可选择性检查或记录日志
    if ([component conformsToProtocol:@protocol(ComponentProtocol)]) {
        // 基础检查通过
    } else {
        NSLog(@"Warning: Component does not conform to protocol");
    }
#endif
}

扩展可能性

1. 批量验证支持

// 扩展:支持批量验证多个协议
+ (void)assertObjC:(id)objc
conformsToProtocols:(NSArray<Protocol *> *)protocols
checkOptionalMethods:(BOOL)checkOptionalMethods
  checkClassMethods:(BOOL)checkClassMethods;

2. 自定义验证回调

// 扩展:支持自定义验证结果处理
typedef void(^ValidationCompletion)(BOOL success, NSArray<NSString *> *errors);

+ (void)validateObjC:(id)objc
  conformsToProtocol:(Protocol *)protocol
checkOptionalMethods:(BOOL)checkOptionalMethods
  checkClassMethods:(BOOL)checkClassMethods
         completion:(ValidationCompletion)completion;

3. Swift 兼容性扩展

// 扩展:更好的 Swift 兼容性
NS_SWIFT_NAME(ProtocolConformanceChecker.validate(_:conformsTo:checkOptional:checkClass:))
+ (void)swift_validateObject:(id)objc
          conformsToProtocol:(Protocol *)protocol
       checkOptionalMethods:(BOOL)checkOptionalMethods
         checkClassMethods:(BOOL)checkClassMethods;

总结

本文介绍了一个简洁高效的 Objective-C 协议一致性检查工具。通过深入理解 Objective-C 运行时机制,我们实现了一个能够全面验证协议实现的解决方案。

核心优势

  • ✅ 完整性:支持完整的协议继承链检查
  • ✅ 灵活性:可配置的检查选项满足不同场景需求
  • ✅ 准确性:严格的方法签名验证确保实现正确性
  • ✅ 简洁性:去除了复杂的缓存逻辑,代码更易于理解和维护
  • ✅ 实用性:清晰的错误报告帮助快速定位问题

适用场景

  • 单元测试和集成测试
  • 框架和库的初始化验证
  • 关键路径的防御性编程
  • 协议实现的调试和验证

通过合理运用这个工具,可以在早期发现协议实现的问题,提高代码质量,减少运行时错误,构建更加健壮的 Objective-C 应用程序。

❌
❌