普通视图

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

用自定义 Layout 化解 SwiftUI List 的行高与间距跳变

作者 Fatbobman
2026年5月27日 22:00

动画的声明式表达是 SwiftUI 的核心优势之一。但在某些场景里,结果并不总像我们期待的那样平滑。一个典型例子是:当 `List` 行内的内容高度发生动态变化——副标题从空变为非空、文本因更新而导致行数变化——系统自带的布局引擎往往无法给出连续的过渡动画。本文从这个现象出发,逐层拆解原因,给出一种完全基于 SwiftUI 原生能力的解决方案;也借这条路径回看 SwiftUI 在布局机制层面的几个关键约束。

雷鸟 GT Max 体验:267 英寸的私人影院,搬家带走只需一秒

作者 郑廷旭
2026年5月27日 20:32

我们去电影院,当然不只是为了那块更大的银幕。

真正让人愿意买票进场的,是灯暗下来之后,画面、声音和注意力一起被收拢的状态。银幕越大,外界越远,人也越容易进入电影本身。

很多人都想把这种体验搬回家。但家庭影院从来不只是买一台设备。

对很多在城市里生活的年轻人来说,客厅不一定属于自己,卧室也很难留出一整面墙。大电视看着痛快,搬家时却很麻烦;投影仪看似灵活,又绕不开亮度、幕布、摆位和墙面。

所以过去很长一段时间里,「在家看巨幕」默认需要一个稳定、宽敞,也适合改造的空间。

雷鸟创新这次发布的雷鸟 GT 系列,解决问题的方式更直接:不再依赖墙面、客厅和摆位,而是把巨幕体验放进一副眼镜里。

它不需要腾出一面墙,也不需要重新安排客厅。戴上之后,观影体验更多取决于眼前这副设备,而不是房子的大小、墙面的条件,或者是不是合租。

两千多元的价格,也让它更像一件可以随身使用的消费电子产品,而非一套围着房子布置的家庭影音方案。

如果说过去的巨幕体验,多少都要先准备一个合适的空间,那雷鸟 GT 系列想解决的,就是先把这个前提拿掉。

把 IMAX 与杜比影院同时揣进口袋

戴上、点亮屏幕的第一眼,最直观的改变是「视野」。

如果你之前用过雷鸟 Air 4 Pro ,换到 GT Max 后会明显感觉到画面变得更加开阔。如果说前代还是「把电视戴在头上」,那么 59° 的超大视场角,则让 GT Max 直接把一座专属的巨幕影院塞进了眼镜里,大幅缓解了以往那种边缘「压眼睛」的局促感。

杜比视界这个画质标准,以往在手机和平板上见得多了,但这次,雷鸟直接把它塞进了这副轻巧的 AR 眼镜里,打造出了全球首款杜比视界 AR 眼镜。

连上雷鸟魔盒 2 ,点开原生支持杜比的《阿凡达:水之道》,你只需要看上几秒钟,眼睛就会直接告诉你答案

当剧情推进到孩子们夜潜入海那一幕,深海背景是纯粹的黑,而纳威人皮肤上的荧光斑点、发光的奇异生物,在画面中呈现出强烈的明暗反差。雷鸟 GT Max 能让黑色的地方彻底沉下去,同时让动物发出的荧光足够明亮,却又完全没有过曝或泛白。阳光穿透水面打在海底的沙子上,丁达尔效应清晰可见,画面的每个细节都一览无余。

除了画质本身足够打动人,眼镜在显示交互上也充分照顾到了实际的动态场景。在右边镜腿的上方,雷鸟设计了一颗物理按键,用于在固定、随行、防抖三个 3DoF 空间模式之间自由切换。在这当中,最让我惊喜的是这个「防抖」模式。

在大多数情况下,它的画面会像一台实体显示器一样相对固定在环境中;但当你要切换姿势——比如从坐姿换成躺姿,动作幅度更大一些的时候,画面又会跟随着你的视线一起缓缓移动。

它完全没有了传统「随行」模式下那种完全跟着头晃、容易导致眩晕的死板感,整个调校非常神奇,体验类似于手机长焦镜头上的那种光学防抖,它能判断什么时候需要「稳」、什么时候又需要重新移动构图。

有了优秀的视觉表现,听觉自然不能拖后腿。雷鸟携手 B&O 打造了这套包含四扬声器与头部追踪的空间音频系统,实际听感有惊喜。

戴上它观看影片时,声音的立体感和空间包裹感相当出色。就拿刚才那段《阿凡达:水之道》的海底夜潜来说,配合影片标配的杜比全景声音轨,当角色潜入水中时,你能清晰地听到头顶海浪的暗流涌动,以及身边鱼群游过时细微的窸窣声。

低频的下潜具备不错的量感,人物的对白也清晰自然。声音的方位感与画面的荧光闪烁同步,真正做到了听声辨位。而且这种开放式的声学设计完全解放了耳朵,你终于可以摘下戴了整天的入耳式耳机,摆脱随之而来的健康隐患。

同时,它的防漏音控制得不错,哪怕是在安静的合租或宿舍环境里,你也可以肆无忌惮地沉浸在自己的私人影院中,完全不用担心会打扰到隔壁的室友。

一块「充电宝」,一个打开沉浸观影体验的「潘多拉魔盒」

雷鸟 GT Max 的惊艳体验,离不开它的「好搭子」雷鸟新版魔盒 2。

它的形态类似一个日常的充电宝,比 iPhone 17 标准版略小也略轻,长时间握持不会坠手。铝合金机身配合正面大面积的 AG 玻璃触控板,滑动起来是高级的磨砂触感,整体质感到位。

它的交互逻辑贯彻了「盲操」和「沉浸」:重力感应移动光标,触控板的交互与手机的全面屏手势一致,支持侧滑返回、底部上滑回主页;右下角贴心地给了一个 TF 卡槽,方便装载自己珍藏的电影资源;

侧边右上角的功能键长按可开启防误触,确保在观影高潮时不会因为误碰而跳出。它底部配备了两个 Type-C 接口,左侧连眼镜,右侧可同时充电,长时间观影也不会有电量焦虑;配合内置的 4000 mAh 电池,不插电情况下也能看完两部电影。

当然,出门在外如果不带魔盒,眼镜也可以直接连接其他设备。无论是插上 Mac 码字,还是通勤路上连上 Switch 沉浸式地推游戏,它都能瞬间化身为一块高素质、大尺寸的「沉浸式」便携屏。

在自带的专为空间计算开发的空间计算芯片 Zone 360 加持下,配合前面提到的三种空间模式,无论是高铁微晃的座椅还是躺平的被窝,都能找到最舒适的观看姿态。

令人惊讶的是,在塞入如此多硬核配置后,雷鸟 GT Max 的重量仅有 78g (标准版雷鸟 GT 更是低至行业最轻的 68g),佩戴起来几乎没有负担。

不过,目前杜比视界功能的体验也有些局限:它被强绑定在了「雷鸟魔盒 2 + 视频平台 TV 端会员」这套组合上。如果你习惯在 Mac 上存放下载好的高清杜比片源,直接拿一根 Type-C 线连上电脑或手机,目前是无法点亮杜比视界的。

此外,受限于棱镜 BirdBath 光学方案的物理特性,在观看高对比度画面时,边缘仍有轻微的光学色散;初次上手,视频平台的扫码登录流程还略显繁琐,需要摘下眼镜,把手机摄像头凑到棱镜前,使用体验确实不够优雅。好在登录好了之后几乎就不用再管了;

魔盒的机身按键略微晃动,且重力光标的指向精度也需要花一点时间去适应。最后的微小遗憾,是目前眼镜和前端播放设备之间,依然无法摆脱一根 Type-C 实体线缆的连接。虽然盲插的设计已经尽力降低了操作门槛,但这种物理上的牵绊,多少还是限制了绝对的自由。

最后来看看价格,雷鸟 GT Max 定价 2599 元,在动辄上探到 4K 档的旗舰 AR 眼镜市场中,还算是相当克制且有诚意的。

它当然不能完全替代电影院。影院里的空间感、声音和那种被迫专注的仪式感,仍然有自己的位置。

但对于更多日常场景来说,雷鸟 GT Max 至少提供了另一种选择:不需要一间影音室,也不需要一面大墙,只要戴上它,就能把一块足够大的银幕带到眼前。

巨幕体验,也能在狭小空间里发生,我想这就够了。

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

再访 XREAL 徐驰:做眼镜是场万米长跑,靠运气也靠打怪升级

作者 杜晨
2026年5月27日 16:26

XREAL 把今年的第一场发布会,留给了一个之前没听说过的新牌子:xbx。

内部的全称是 x, by XREAL。

考虑 1699 的定价,xbx 的第一款产品 a01 的性价比相当不错:50° 视场角,tandem OLED 显示模组的亮度高达 1600 尼特,等效 4 米左右 147 英寸大屏,支持 HDR10 和在至高 120Hz 帧率下的空间防抖。

但参数远没有颜值和戴起来轻松更重要。62g,半透明未来感机身,可替换的多种个性化镜框。CEO 徐驰说,「颜值就是正义,只管玩就好。」

这是成立十年来一直在拼了命地往「上」冲的 XREAL,第一次「向下」。

过去这些年,徐驰和他创立的 XREAL 从来没有走过容易的道路。尽管中国的消费电子供应链资源足够好,以至于整合能力成为了成功的硬件创业者必备的素质——XREAL 却是不搞纯粹的「供应链整合」的。

正相反,XREAL 一直都在往上走,做最贵、最难、最「极客」的产品。为此,XREAL 不惜做极高比例,同时也是同行中最高比例的自研,甚至不惜因为过去两三年里的国际半导体波动,而损失相当一部分利润率。

这才是为什么去年 XREAL 能和硅谷巨头 Google 联合推出 Project Aura,一台令行业人士刮目相看,也让消费者打破对现有「智能眼镜」刻板印象的原型机(今年将正式面市)。徐驰毫不收敛地将 Project Aura 称为他所在的行业体验的「天花板」。

能做出这样的产品,断不可能靠整合供应链。为什么只有 XREAL 能做到,为什么谷歌选择了,LG、ROG 等也都选择了 XREAL?

徐驰说,答案是 XREAL 的 A 面:内敛、冷峻、长期主义、押注绝对的技术创新。

那么,XREAL 的 B 面又是什么?

在徐驰带领着公司一往无前地朝着头戴式显示技术的性能优化与极致轻量化冲刺的同时,他的背后险象环生:

在通过 Apple Vision Pro 试水也试错了之后,苹果立项了更多轻量级 AI/AR 眼镜产品,如无意外将于 26H2-27 全年逐步问世;小米、阿里千问、Rokid、VITURE 等纷纷杀入市场。

它们当中,有的用 AR 显示眼镜围攻 XREAL 占据已久的光明顶,更多的则是用 AI 眼镜(屏显/无屏)来提前抢占行业领头者尚未明确布局的新空间——无论何种产品定义,价格都被压得越来越低。

对此徐驰并不紧张。在和他深聊过后,爱范儿得出这样的感觉:XREAL 的 A 面朝前太久了,以至于同行们似乎误认为它没有或者不屑于展现另一面。

「怎么说呢,A 面没立住的时候,就没有 B 面。我们现在来了,虽迟但到。」

XREAL 主品牌的势能已经积攒到位,徐驰和他的产品团队终于腾得出手去做另一种风格的产品——一个更年轻、外放、价格也更亲民的牌子 xbx。

这就是 XREAL 的 B 面,与那个永远创新不止的 A 面,互为映照。

他说,自从创业以来,见到了 VR/AR 的泡沫,破了;然后元宇宙来了,也破了。一路走来,这次创业越来越像一场万米长跑——这也是从一开始他就坚信的赛制。「抢跑一点都不重要,跑对方向才重要。」

所以,徐驰看起来并不担心这些新来的竞争者。问他怕不怕大厂和其它创业公司一拥而上,他答:「我们最怕的,是这个行业只有自己。如果没有别人,没准说明我们走错了方向。所以热闹一点挺好的。」

2016 年,徐驰离开混合现实先驱企业 Magic Leap,回国草创,做一副在当时没人看好的眼镜。

快十年过去了,他庆幸 XREAL 能活到今天,运气占了相当大的因素。

「感谢这个赛道前十年的起起伏伏,让我有机会不断打怪升级……等到真的要跟大厂掰手腕的时候,不至于一上来就是总决赛。」

爱范儿等与徐驰、XREAL 产品负责人刘宗楷进行了一次专访,从全新的子品牌 xbx 和第一副价格打到 1699 元的 AR 眼镜产品 a01 聊起,一路聊到他怎么看待竞争,如何比较自己与同行之间的资本效率、AI 眼镜会不会最终取代手机,以及一个第一次创业的人,凭什么活到今天。

「年轻人最好的,就是不迷信传统」

爱范儿:XREAL 这些年的特质就是高端化,为什么要在这个时间点推出 xbx 这个品牌?

徐驰:我们一直说,今天的智能眼镜行业很像 05、06 年的手机行业,很碎片化,系统、应用生态、交互范式都不统一。在这个相对早期的阶段,没有哪个品牌能够覆盖所有的价位段。所以我们就想,有没有可能做两种风格不一样的产品,像 A 面与 B 面一样。

这个行业充满波折,是出了名的难做。很长一段时间里,大家都在摸索,我们自己也(一段时期内)没有一个特别清楚的定位。但是「XREAL 」在我心里就是那个极致创新的品牌,更冷酷、更经典。但是,一个品牌尚未立住的时候,我没办法再去做一个更大众、更宏观的东西。

慢慢地,XREAL 成为了我想要的那种更内敛的品牌,这时候,就可以有一个更绽放的品牌跟它相互映照了。这就是我说的 A 面与 B 面。A 面没立住的时候,就没有 B 面。

这件事虽迟但到。从今往后,我们不只是一家叫 XREAL 的公司,还是一家 x by XREAL 的公司。

爱范儿:年轻人想要什么样的 AR 眼镜?难道年轻人就不想要极致的产品?

刘宗楷:对年轻人来说,个性与自我表达是每个人心里的渴望。市面上不管是 AR 眼镜、AI 眼镜、还是 XR 头盔,很多人下意识觉得这东西就该不好看、不轻便。但我们偏要反着来,为什么不先做出一副好看、够轻、年轻人愿意戴在头上的眼镜?一副愿意戴出门的眼镜,是所有事情的第一步。

徐驰:年轻人最好的,就是不迷信那些传统的大道理。颜值就是正义,好看就好,好用就好。我们希望用 a01 这副眼镜让大家明白,一千多块的价格也可以做到两千多块的体验。我们会把它长期做下去。

爱范儿:必须戴到外面,才能影响更多潜在受众。

徐驰:没错就是这样。我们希望这个产品可以在地铁上,在咖啡馆里,在飞机上,在各个地方,更多的年轻人把它戴到外面,而且是不尴尬的。所以我们做了极致的轻量化和个性化设计。

爱范儿:轻量化肯定有取舍。一个产品想做更高的分辨率、帧率、视场角,模组就会变大;模组大了,重量就会大、配重也会失衡。

刘宗楷:做轻的同时还要保证体验,真的非常难。镜片和外壳的厚度能不能再降一点,但强度还能保住。每一个器件既要轻还要保住性能,我们抠了很多细节。

这条路没有尽头,就是一个个夜晚,一次次较劲和争吵。当然,我们觉得还可以做得更好。

爱范儿:AR 眼镜能做到的 FoV(视场角)物理极限是多少?以及不考虑极限,只说在不同场景下人类佩戴的人体工学舒适度,最优解是多少?

徐驰:我给你个最直接的答案,最好的视场角应该是在 85° 左右,但这是在不计成本堆料、不考虑重量的前提下。

在 Project Aura 上,我们做到了 70°,在这个产品形态下已经是非常不错了,但是仍然有差距。什么时候我们能做到 85°,并且仍然是轻量化的,那么我们会觉得至少在显示端做到极致了。

刘宗楷:根据场景来看,比如你戴上 VR 头显去火星,画面主体是一艘宇宙飞船,背景则是宇宙星空——你需要同时看到主体和背景才能获得最大的沉浸感。但是对于 AR 眼镜,最好的背景其实是真实世界。如果是打游戏或者看球赛可能就不需要很大的角度;但如果是看电影,或者附着在真实环境里的 AR 显示,那么宽视角的沉浸感就更重要。所以最终还是取决于内容是否沉浸。

至于人眼的注意力聚焦视角,从眼科学上来说的确有极限,一般就是水平方向的 50° 左右,垂直的 30-40°这个区域内。

视场角并不是唯一的关键因素,还有电致变色、性能续航等等。在我们定义不同产品的时候,会有无数个取舍的拨杆,往不同的方向去拨。

「我们最怕的,是这个行业只有自己」

爱范儿:苹果也入场了,国内大厂的竞品也已经上市,价格越压越低。你怎么看?

徐驰:大家进来,我认为是好事。我们最怕的是什么?是这个行业只有我们自己——那说明这个赛道没人关注,没人看好。

我们始终认为,眼镜是最有机会替代手机的下一代计算终端。虽然已经创业十年了,我们也才刚刚开始,我们的渗透率可能还不到 1%,后面还有百倍甚至更大的成长空间,所以大家一起来把蛋糕做大是件好事。

我们这个行业是有泡沫的,但泡沫不一定是坏事,说明大家对行业的期待值很高。过去在每个阶段,都有过想挣快钱的人,发现不好赚就走了。泡沫褪去,受害的其实是消费者。而真正推动行业往前走的,是那些把「用户期待」和「产品体验」之间的差距一点点缩小的人。

打个比方,今天的 AI 眼镜就像五岁小孩,而我们定义的全天候佩戴的 AI 眼镜就像贾维斯。这中间的差距得靠底层创新去一点点推动。这些创新不会无缘无故发生,背后一定有人负重前行。

问:你们跟 Meta 的距离还有多少?

徐驰:举个不那么恰当的对比:2025 年 Meta 的 Reality Labs 业务营收是 22 亿美元,亏损接近 200 亿美元。我们今年做到了 2 亿美元营收,差不多是它的十分之一,但我们的亏损不到 2000 万美元。

十分之一的营收,千分之一的亏损,我觉得我们的资本利用效率还可以,这也是我们的优势。

爱范儿:你们有自己的全栈自研芯片、光学,但 Project Aura 的部分算力还是用的骁龙,两者这两者是什么关系?将来 XREAL 会否提高核心算力的自主性?

徐驰:X1S 是一颗完整的 SoC。在 Aura 上,所有对延迟和带宽敏感的计算,放在我们的 X1S 芯片上,其它的给骁龙。

我们的芯片就是纯端侧计算,骁龙芯片放在 puck(外挂的计算单元) 上。这两者不是处理器和协处理器的关系,而更像是「端侧」和「云」之间的关系。有些计算需要发生在离你更近的地方,更加及时。

我们一直说眼镜会取代手机。在可预见的将来,puck 会消失,直接换成你的手机就行了;更长远来看,如果眼镜真的取代手机,它需要自己能够处理所有的计算。这才是我们为什么押注自主芯片的意义所在。

前段时间美国出台禁令,先进制程的晶圆不能直接运进中国大陆。这件事挺流氓的,我们的芯片在这个范畴内,本来要在大陆做封装,结果必须在台湾封装完才能运回大陆。当时国内一大批芯片厂商都在争抢台湾的封装资源,造成了一次性的短缺,跟今天的内存短缺很像。为此,我们的业绩也少了蛮大一块,否则去年 Q3、Q4 的增长会很明显。

但从长远看,这反而驱动我们继续往前走。还好我们今天销量不是很大,总比卖了几百万台突然被卡脖子要好(笑)。我们希望中国有越来越多的先进制程握在自己手里,谁也卡不住。

爱范儿:Project Aura 在国内能上吗?你们会选择哪些国内模型厂商一起探索?

徐驰:因为 Android XR 和 Gemini 强绑定,而 Gemini 在国内用不了。所以很遗憾,要不你海淘吧(笑)。

我们不会放弃国内市场,如果 Android XR 能够和 Gemini 解耦,连上国内 AI,就是 Project Aura 进入国内市场的时候,但不是今天。就像当年 iPhone 也不是刚问世就进入中国。我觉得这个结果我们可以接受。It’s okay.

对我们来说,阿里是我们的股东,我们也一直跟字节跳动保持交流。在模型方面,我们不会排斥任何一家。我们的终极理想,是 AI 能像搜索引擎一样换着用。未来的大模型会变成基建,谁家的 token 效果好就用谁,可以无缝切换。

「眼镜凭什么取代手机?」

爱范儿:你自己也说,AR 眼镜这个品类存在很多年了,但渗透率仍然很低。让更多人接纳它的「入门毒药」会是一个怎样定义的产品?

徐驰:大概率还是主流两大类:更加全天候的 AI 眼镜、带显示但不够全天候的 AR 眼镜。

这个「全天候」(always-on) 有两层意思:一是全天候佩戴,二是全天候使用。今天的问题是,AI 眼镜的主要场景不是 AI,而是听歌拍照;你打开相机拍个 30 分钟,产品就没电了。如果说眼镜是你的个人助理,但它每天只能睁眼 30 分钟……那就不是一个全天候的助理。

在将来的某个时间点,会有一款 35g 以下、全天候续航的产品,作为 AI 交互的载体。这样的产品,我认为是能做到的。如果做到了,它绝对会是人手一个的设备。

另一条路就是 AR 眼镜,追求更高清、更多内容。这个路线今天还是分体机形态,能做到 60g,但终极形态可能会是一体机。

这两种产品,一个像 iPhone,人手一台,整个品类可能是每年十多亿台的出货量;另一个像我们现在的设备,做到终极形态可能是平板和笔记本电脑加起来的体量,一年 1.5 到 2.5 亿台,也很不错;以及传统头显,可能体量会像台式机——这三者会长期共存。

至于那个彻底引爆品类,将眼镜真正推上「取代手机」道路的产品是什么,我认为到 2027、28 年,我们会看到更清晰的答案。

爱范儿:即便做到了极致的轻量化,你怎么说服那些仍然嫌重的客群?

徐驰:我认为今天大家太容易先行代入刻板印象,比如「没有 35g 绝对不戴」。今天的行业里,抛开补贴的产品,还没有不吃国补、销量过百万的产品。如果真能达到 35g,早就是 15 亿台的水平了。

我们得一步步来:先把一个单品做到百万,再做到千万,再做到一个亿、15 亿。中间有好多级台阶。我相信在今天,一副体验足够好的眼镜,50g 也不妨碍它卖一个亿。影响接受度、卡住销量的只是体验还没有打磨足够好。

爱范儿:手机厂商觉得未来 5-10 年里手机仍是主角。但与此同时手机厂商也在做眼镜。你看到的未来竞争格局是怎样的?

徐驰:的确,今天存在的东西,很长时间内仍然会存在。但核心是谁能站在价值链的最高点。就好比曾几何时我们觉得互联网大厂的超级 app 太牛了,但今天它们的风头一定没有 AI 公司更盛。手机也是一样。随着科技发展,总有一些新的领域、企业,会站到价值链更高的位置。

我们相信未来两年内会形成共识:眼镜是离 AI 最好的原生终端,它可能是离多模态 AI 最近的东西。这也是为什么我们跟谷歌一起去畅想未来的全新交互范式,以及新范式下的终端长什么样。

这件事令我非常兴奋,一是因为它难,二是如果做对了,会非常 rewarding。

爱范儿:其它形态的 AI 硬件,比如 pin、带摄像头的耳机,不如眼镜吗?

徐驰:不光我这么想,Demis Hassabis 也说眼镜绝对是所有 AI 里最中心的设备。因为只有眼镜能够拿到人的关注点这一关键上下文信息。

你戴了一个 pin,它能看到你面前有一堆人,但眼镜在未来会有眼动追踪,它能知道我当下到底在看什么,周围的信息可能没那么重要。只有眼镜能带来端到端闭环的数据链路,其它终端都不具备这个能力。当然别的形态可以辅助,但眼镜一定是最关键的入口。

「靠运气,也要打怪升级」

爱范儿:创业者、企业家会有不同恐惧来源,可能是内部的组织效率跟不上时代,可能来自同业的竞争,可能来自异业的颠覆。足以让你从睡梦中惊醒的恐惧是什么?

徐驰:做企业和做人一样,做人也会迷茫,有人给你指点,让你找到对标。但我觉得说到底,烦恼都是自己给的。

我相信伟大的企业全是价值观驱动的。最核心的就是找到一个组织舒服的状态,让全公司都认可你的这套价值观——无论离开还是留下,都会继续在这套价值观的规范下做事。只要这件事做到了,竞争也好,别的也好,其实都还好。

我个人睡眠还挺好的,我觉得这是创业者得有的一个特质(笑)。

要说真有什么让我担忧,那就是我所崇尚的价值观,是否真的能够百分百贯彻执行?我怕的是 XREAL 变大了,文化会被稀释掉。我需要大家打心底里相信一件事:我们要当创新者、引领者。这不容易,在中国尤其不容易。在中国大家的习惯是服从等级制度,「老板说的都对」,可我还是希望,大家既能自下而上,又能自上而下,形成一个扁平高效的机制。

爱范儿:就像你说的,几轮泡沫起起伏伏,XREAL 还是活到今天了。

徐驰:2016 年我从 Magic Leap 回国,到今天整整十年了。那时候真是草根创业,我就是想做一副眼镜。能活到今天,回头看真是运气挺好。这是我的第一次创业,也感谢这一路的投资人(以及其他同行者),让我在这个过程里慢慢理解了怎么去运作一家企业,一个组织,一门生意。

说实话,如果这个行业发展再快一点,起势再猛一点,没机会把自己磨练好,去应对巨头杀进赛道时那种强烈的竞争,可能我们就没了。

每个创业公司大概都得经历这么一段:你得先打怪升级才能站上更大的舞台。如果一上来就是总 boss,来一帮阿里字节那样的对手就没得打了。所以我其实挺感谢这个赛道前十年的起起伏伏,才有一天让我能跟大厂掰一掰手腕。

AR 行业是出了名的难做,我又干得有点久了,所以对这些事现在看淡了。只要大家都还在牌桌上,这就是一件长期主义的事情。

我认为 AR 是一场万米长跑,跑对方向比抢跑更重要。如果行业还在早期但所有人都往一个地方冲,那个所谓的共识可能就是泡沫。反而是早期非共识的东西,最后被时间验证是对的。历史无数次这样告诉我们。

文|杜晨

采访|杜晨

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

昨天以前首页

从社区路标到生态基石:Dave Verwer 的新篇章 - 肘子的 Swift 周报 #137

作者 Fatbobman
2026年5月25日 22:00

Dave Verwer 在 iOS Dev Weekly 第 751 期宣布,这份已经持续近 15 年的周报将交由新的团队继续运营,而他自己接下来会全职投入 Swift Package Index。我的博客在早期获得关注,也曾得益于 iOS Dev Weekly 的推荐;而我在周报中坚持撰写每期周评,同样在很大程度上受到 Dave Verwer 的启发。对于很多 Apple 平台开发者来说,iOS Dev Weekly 早已不只是一份链接合集。它既是社区路标,也是长期陪伴。

谷歌用 AI 「杀死」谷歌,这场发布会看得人缺氧

作者 张子豪
2026年5月20日 05:52

Gemini App 月活超 9 亿,月 Token 处理量每月 3200 万亿,Nano Banana 生成超过 500 亿张图片……

在今天凌晨刚刚结束的 Google I/O 大会上,Google CEO  Pichai Sundar上来就抛出了这些数字。

过去一年,AI 成了所有行业的主旋律,Gemini 在 Google 的定位,也开始从一个独一的 App,成了所有 Google 产品里的最重要的 AI 底层能力。

这次发布会也先从模型开始,进一步带到 Coding 和 Agent 产品。

Gemini Omni 把 Google 的视频生成推向「世界模型」方向,Gemini 3.5 Flash 则是和 AI 编程工具一起推向 Agent 开发平台。

这两个能力随后进入 Google 的完整生态,搜索、Gemini App、Flow、Spark、Chrome、XR 眼镜和电商场景。

Gemini Omni 登场,视频界的「Nano Banana」时刻来了

发布会最先被重点展开的是 Gemini Omni。

DeepMind CEO 将 Gemini Omni 描述为一个能够「从任何输入创造任何内容」的新模型。它把 Gemini 的推理能力与 Google 既有的生成式媒体模型结合起来,目标是提升模型对世界的理解、多模态生成能力和编辑能力。

Google 强调,Veo、Nano Banana、Genie 等模型已经能生成视频、图片和交互式模拟,但 Gemini Omni 更进一步,开始处理动能、重力等更接近物理世界的问题。

发布会现场展示的案例包括蛋白质折叠解释视频。用户只需要输入类似「生成一个关于蛋白质折叠的黏土动画解释」的提示,Omni 就能把抽象科学概念转化成视频内容。

它还支持更自然的视频编辑。用户可以上传自己的视频,再用对话方式修改风格、加入元素、调整细节,甚至把一个普通圆形转成黑洞,把夜晚散步场景变成更具戏剧感的画面。

Google 的说法是,Gemini Omni 先从视频开始,之后会逐步走向「任意输入到任意输出」。这也是 Google 一直把 Gemini 设计成多模态模型的原因。

首个 Omni 家族模型 Gemini Omni Flash 已在上线到 Google 产品中,Omni Pro 会在之后公布更多信息。Gemini App 中的 Omni 功能也面向 Google AI Plus、Pro 和 Ultra 订阅用户开放。

这意味着,Gemini Omni 不只是一个视频生成模型。Google 想把它放进「世界模型」的叙事里:模型不仅生成画面,还要理解画面中的物理关系、运动关系和场景逻辑。

在进入 Gemini App、Google Flow 和 YouTube Shorts 这些应用之后, Omni 也会让 Google 的生成式创作工具从图片编辑扩展到视频编辑。

Gemini 3.5 Flash 上线,AI 写代码进入极速模式

如果 Gemini Omni 对应的是生成和编辑,Gemini 3.5 Flash 对应的就是速度、成本和执行能力。

Google 在发布会上推出 Gemini 3.5 Flash,称它是 Gemini 3.5 系列第一批模型之一,重点面向 agentic coding、长周期任务和真实工作流。

相比 3.1 Pro,3.5 Flash 在几乎所有基准测试中提升明显,尤其是代码能力,以及 GDPVal 这类更接近真实经济任务的评测。

Google 还强调,3.5 Flash 在输出 tokens 速度上比其他前沿模型快 4 倍,在 Antigravity 中经过专门优化后,速度可达到 12 倍。

值得一提的是,今年 3 月,Google 内部开发相关任务每天处理约 5000 亿 tokens,之后每隔几周翻倍,目前已经超过每天 3 万亿 tokens。Google 把这称为一个反馈循环,用大规模真实使用继续改进 3.5 Flash。

与模型同步推出的是 Antigravity 2.0。

它从原来的 agent powered IDE,升级为一个独立桌面应用,重点转向 agent first。用户不再只是让 AI 在编辑器里辅助写代码,而是通过 Agent 对话、Agent 产物和多 Agent 协同来完成开发任务。

Antigravity 2.0 加入完整 CLI、Antigravity SDK、Gemini 音频模型原生语音支持,并集成 Android、Firebase、Google AI Studio 等服务。Antigravity 2.0 作为独立桌面应用,也已经面向全球用户开放。

Google 在现场用一个高强度演示解释 Antigravity 2.0 的方向:让 Agent 从零构建一个可运行操作系统。这个任务由 93 个子 Agent 并行执行,持续 12 小时,发起超过 1.5 万次模型请求,处理 26 亿 tokens,从空项目生成调度器、内存管理、文件系统等核心模块。

Google 称,这件事在 Gemini 3.1 Pro 上无法完成,而使用 Gemini 3.5 Flash 消耗不到 1000 美元 API credits。

现场还演示了这个系统运行 SL 小火车程序和 Doom。由于系统最初缺少视频和键盘驱动,Antigravity 又继续生成相关代码并修复,让 Doom 能够运行。Google 还称,类似方式已经测试过照片编辑套件、实时消息应用、多用户协作平台等项目,原本需要多天的工程工作被压缩到数小时甚至更短。

Gemini 3.5 Flash 已面向所有用户开放,覆盖 Google 产品和 API。Gemini 3.5 Pro 仍在内部使用和改进中,预计下个月开放。

从搜索框到信息 Agent,Google 重做 AI 搜索

模型和开发工具之后,Google 把重点转向搜索。Google 搜索也就是 AI 搜索。

Google 表示,AI Mode 已经超过 10 亿月活,查询量自推出以来每季度翻倍。

今天起,AI Mode 升级到 Gemini 3.5。新的智能搜索框也从当天开始推送。它支持文本、图片、文件和视频输入,并在用户输入问题时给出 AI 建议。

AI Overviews 和 AI Mode 也被合并成更连续的 AI 搜索体验。用户可以先在主搜索结果页看到 AI 回答,再进入 AI Mode 继续追问,上下文会被保留。这个新搜索体验已在发布会当天面向全球桌面端和移动端上线。

更大的变化是搜索 Agent。

Google 表示,用户今年夏天可以在 Search 中创建信息 Agent,让它持续跟踪某类信息。例如,用户可以让它监控市盈率低于 15、现金流为正、负债较低的大型生物科技股票;也可以让它长期跟踪租房信息、球鞋联名和商品上新。当条件变化时,Agent 会给用户发送综合更新。

Google 还把 Antigravity 的 agentic coding 能力带入搜索。

之后搜索不只返回网页、摘要或卡片,也能为具体问题生成交互界面。比如用户问「黑洞如何影响时空」,Search 可以生成一个交互式视觉组件;继续追问「双黑洞如何产生引力波」,Search 会重新生成一个可调参数的动态界面。Generative UI with Antigravity 将在今年夏天面向所有用户免费推出。

更复杂的自定义体验也在路上。

Google 现场展示了一个周末计划器,Search 会结合天气、地图、用户偏好、Gmail、Calendar 等信息,生成一个可以继续修改、分享和同步日历的小型工具。这类自定义体验将在未来几个月先面向订阅用户开放。

关机也能跑,Gemini Spark 把 Agent 能力搬进个人生活

消费端最重要的新产品是 Gemini Spark。

Gemini Spark 是一个个人 AI Agent,运行在 Google Cloud 的专用虚拟机上,可以全天候执行任务。它由 Gemini 3.5 和 Antigravity harness 驱动,支持长时间后台任务。

用户关掉电脑后,Spark 仍能继续工作。它先接入 Google 自家工具,未来几周会通过 MCP 接入第三方工具。

发布会展示了 Spark 的几个典型场景。

用户可以让它汇总过去一周 Gemini Live 的发布和进展,从 Docs、Gmail 和聊天记录里提取信息,再用个人写作风格生成团队邮件。也可以让它管理街区派对,维护 Google Sheets RSVP 表格,跟踪谁带了什么东西,给没报名的邻居生成提醒邮件草稿,并自动生成 Google Slides 宣传页。

Spark 还支持手机端语音输入。

用户可以一次说出多项任务,比如把所有与 Sundar 的会标成亮粉色,给新邻居写邀请信,创建孩子学年结束前待办文档。Spark 会把这些内容分成多个独立任务,并在后台执行,结果可以在手机和电脑之间同步。

Gemini Spark 本周面向可信测试者开放,下周以 beta 形式面向美国 Google AI Ultra 订阅用户推出。

Google 同时推出每月 100 美元的新 Ultra 计划,并把最高档 Ultra 计划从每月 250 美元降至 200 美元。今年夏天晚些时候,Spark 将进入 Chrome,成为能在网页中执行任务的智能体浏览器。

Gemini App 大改版,还有 Google 版「AI 晨报」

Gemini App 本身也迎来了一次脱胎换骨的大改版。

Google 引入了全新的设计语言 Neural Expressive,加入流体动画、鲜艳色彩、新字体和触觉反馈。

新版 Gemini App 不再把回答呈现为大段文字,而是会根据内容实时生成更适合阅读和操作的布局,包括交互图片、时间线、嵌入式视频等。Neural Expressive 现在已经在 Android、iOS 和网页端全球推送。

Gemini Live 也被重做,打开后可以直接进入实时对话。区域口音选择将在未来几周推出。

Gemini App 还加入 Daily Brief。这是一个面向早晨使用的个性化摘要 Agent,会综合 Gmail、Calendar、Tasks 等信息,整理用户当天需要关注的事项,并给出下一步行动入口。

Daily Brief 今天起面向美国 Google AI Plus、Pro 和 Ultra 订阅用户推出。

在更大的 Gemini 叙事之外,Google 也更新了几个日常产品。

Google Maps 最近完成十年来最大升级,并加入 Ask Maps。它允许用户提出更长、更复杂的问题。例如,发布会举了一个场景:孩子掉进鸭塘,婚礼 30 分钟后开始,用户想知道哪里可以步行买到新裙子。

Docs 也获得新的语音创建能力。用户不需要输入精确提示词,可以直接用语音把想法说出来,让 Gemini 从 Drive 调取简历,从 Gmail 找到活动信息,再生成 Google Docs 草稿。这个能力将在今年夏天面向 Pro 和 Ultra 订阅用户推出,同类语音能力也会进入 Gmail。

生成能力升级后,内容来源识别也变得愈发重要。

Google 称,SynthID 推出三年来,已为超过 1000 亿张图片和视频,以及相当于 6 万年时长的音频加上不可见水印。接下来,SynthID 和内容凭证验证会扩展到 Search 和 Chrome。

用户可以通过圈选搜索,或者在 Chrome 中右键询问内容是否由 AI 生成,系统会显示内容来自 AI、相机,还是曾被生成式 AI 工具编辑。

Google 还宣布,OpenAI、Kakao 和 ElevenLabs 将采用 SynthID 2。此前英伟达已经加入 SynthID 体系。对 Google 来说,SynthID 不只是安全功能,也是争取 AI 内容透明标准的一部分。

Google 创作全家桶,开始围攻图片、设计和视频

在创意工具领域,Google 密集发布了多款重磅产品。

Google Pics 是 Google Workspace 中的新图片创建和编辑产品,面向派对海报、信息图、宣传图等场景。用户可以从一张基础图开始,删除元素、调整对象大小、编辑文字和翻译文字。Pics 生成内容会带有 SynthID 水印。Google Pics 将在今年夏天推出。

设计产品 Stitch 也迎来更新。用户可以通过一句 prompt 生成网站或应用界面,再通过文字或语音继续修改,比如放大标题、调整菜单、突出更多披萨选项。Stitch 支持把设计导出为代码,或直接发布网站,相关更新现已发布。

Google Flow 的更新尤为关注。Gemini Omni 进入 Flow 后,用户可以基于原始视频改变环境、添加视觉效果、加入新角色,同时尽量保留原有表演。

Flow 还加入新 Agent,支持一次执行多个动作。比如从单张图片生成 16 个不同机位的视频,或把一组清晨场景批量改成深夜场景。

Flow Tools 则允许用户在 Flow 中创建自己的创意工具,比如视频特效、手绘动画和文字分层工具,并支持分享和 remix。

Google Flow Music 可以把一段钢琴 riff 扩展成带风格方向的音乐 demo。Google Flow 和 Google Flow Music 的这些新功能已上线。

押注智能眼镜,Google 再闯下一代入口

硬件部分,Google 也把 Android XR 这个操作系统级平台,从头显、XR 设备,进一步扩展到智能眼镜形态。

Android XR 是 Google 与三星合作,并针对 Qualcomm Snapdragon 优化的平台。

Google 表示,AI 眼镜会分成两类:一类是带小型镜片的显示眼镜,另一类是音频眼镜。显示眼镜去年已在 I/O 展示,今年首批开发者已经开始创建显示体验,可信测试者计划将在今年晚些时候扩大。

更早上市的是音频眼镜。

Google 宣布,首批音频眼镜将在今年秋季推出,由三星参与硬件和体验构建,Warby Parker 与 Gentle Monster 负责眼镜设计。这些眼镜连接手机,支持 Android 和 iOS。Gemini 的回答通过耳机私密播放,而不是显示在镜片上。

发布会上,演示者可以通过眼镜让 Gemini 导航到上周和朋友见面的地方,中途加入咖啡店;也可以让 Gemini 打开 DoorDash 自动下单咖啡,等待用户确认;

还可以让它总结静音消息,并把家庭晚餐写入日历。眼镜还可以与手表配合,让用户拍摄现场照片,并用 Nano Banana 生成卡通图像,再在手表上预览。

发布会最后,Gemini 的使用场景也延伸到了网络安全场景。

Google 介绍了 CodeMender。它是一个代码安全 Agent,能够自动寻找和修复关键软件漏洞。Google 将邀请一批专家测试 CodeMender API,之后会更广泛推出。

整场发布会看下来,信息量大到让人有些缺氧。只是当这些 AI 功能真正开放给几千万、几亿人使用时,一个最现实的算账问题就直接摆在了面前:这笔庞大的算力开销,Google 要怎么挣回来?

过去二十多年,Google 代表的是一种典型的免费互联网模式。用户用注意力和数据换服务,Google 用广告和分发赚钱。这套模式让 Google 成为互联网时代最强的基础设施公司。

但大模型推理的成本,和查询一次搜索结果完全不在一个量级。

长上下文记忆、多模态生成、跨应用 Agent、企业级自动化,这些能力背后都是持续运行的算力消耗。AI 越深入,Google 越难继续用「免费功能升级」的方式来消化成本。

这就是为什么整场发布会下来, Google I/O 看似讲的是体验升级,背后指向的却是订阅、企业合同、算力账单和长期服务费。

免费入口当然不会消失,因为那仍然是 Google 获取用户、数据和生态位置的基础。但在这些入口之上,Google 正在叠加一个新的智能服务层:更强的模型、更长的记忆、更深的系统权限、更复杂的任务执行,以及更稳定的企业级服务。

换言之,Google 正在从免费互联网服务公司,进一步变成 AI 订阅基础设施公司。

只是,问题也随之而来,用户愿意为搜索付费吗?通常情况下,不会。

可是,如果这是一个能替你全天候处理邮件、统筹任务、分析报表、接管智能家居,甚至还能帮你写代码开发 App 的「超级全能助理」呢?你愿意为它每月掏出几十上百美元吗?

这,正是今年 Google I/O 迫切想要验证的核心商业命题。而环顾如今狂热的市场,答案似乎早已不言而喻。

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

消失的 WWDC 愿望单 - 肘子的 Swift 周报 #136

作者 Fatbobman
2026年5月18日 22:00

距离 WWDC 2026 只剩下 20 天了。每年到这个时候,我都会看到不少开发者分享自己的 WWDC 愿望单,写下预测与期许。但今年,至少到我汇总本期周报时,这类内容相较去年同期明显少了许多。究竟是开发者对 WWDC 的期待变淡了,还是更多人开始秉持“降低预期才能获得更多惊喜”的心理?

Markdown 已死,HTML 当立?

作者 张子豪
2026年5月12日 14:15

人类花了半个世纪把文档从打字机搬到 Word,又花了二十年搬到云端。结果 AI 时代真正的通用格式,是一门 2004 年发明的纯文本语言—— Markdown。

最近 Claude Code 工程师 Thariq 又提出了新的观点,说自己已经不用Markdown,HTML 才是未来,引发了大量讨论。

▲ Claude Code 工程师 Thariq 分享的用 HTML 替代 Markdown 文章,当前该内容已在 X 上获得千万次浏览

文章里,他提出了 HTML 格式的输出,是比 Markdown 文本更好的形态。对 AI 来说,从输出 Markdown 到输出 HTML,转换的过程基本无痛,但对用户来说却是实打实的体验优化。

Karpathy 在今天凌晨也转发了这篇文章,分享了他对于 HTML 的看法。

在他看来,音频是大语言模型最好的输入,视觉是最好的输出。在他畅想的路线里,HTML 之后还有交互动画、神经网络直接生成的视频、最终某种人机之间真正的感知融合。

在 Vibe Coding 和 Agent 产品成为主流的背景下,HTML 和 Markdown 对大多数 AI 玩家可能并不陌生。

▲ 在 DeepSeek 内要求它做一个小游戏,会直接给我们一段能运行的 html 代码文件

想做一个小游戏,告诉 ChatGPT,「帮我做一个贪吃蛇的单页 HTML 网页」。ChatGPT 会将代码打包成成一个后缀名为 html 的文档,双击打开,我们就能在浏览器里看到一个可交互、有动效、图文丰富的成果。

甚至在浏览器里面,任何一个网页下,按下 CTRL+S,保存下来的本地文件,都有一个 .html 的文档。

而 Markdown 从 AI 要获取网页上下文的年代,就有大量的工具,直接将各种文件类型的文档转成 Markdown 格式。

微软自己作为办公三件套之王,有着 docx、pptx、xlsx 等职场常用的文件,早前也开源了一个将这些办公文档转成 Markdown 格式的项目,目前在 GitHub 上已经收获超过 12 万 Stars。

▲ 项目地址:https://github.com/microsoft/markitdown

OpenClaw 爆火之后,各种 AGENT.md、SOUL.md、CLAUDE.md、MEMORY.md……甚至是 Skills 工程里面,每个 Skill 也是一个 Skill.md 的文档。

从记忆的保存、到提示词和 Agent 的控制,Markdown 格式几乎成为了 AI 获取丰富上下文的不二选择。

▲ OpenClaw 智能体会通过多个不同的 Markdown 文件来搭建最终的工作区|图片由 AI 生成

我们日常工作中最常使用的 PDF、DOC、以及 PPT 反而在 AI 时代成了「最不被待见」的格式。

但现在冒出来的 HTML 的又是怎么一回事,它会有机会取代 Markdown 成为 AI 时代的新通用语言吗?

Markdown 为什么最适合 AI

先说说为什么 Markdown 成为了 AI 时代的 Word,无论是 AI 的回答,还是我们丢给 AI 的上下文,现在大多都是以 Markdown 为主。

这门语言诞生于 2004 年,灵感来自 2000 年代电子邮件的文本排版惯例——竖线分隔、80 字符换行、星号表示强调。它的目标是「写起来像纯文本,渲染出来像 HTML」。足够简单,足够便携,不需要任何工具,任何文本编辑器都能处理。

▲ Markdown 语法速查表|图片由 AI 生成

这套设计哲学在博客时代是完美的。2008 年前后随着 Github 崛起,Markdown 直接成为程序员的标准写作格式。各类技术文档、Stack Overflow 回答、Github README、技术博客,Markdown 几乎在所有这些场景里都工作得很好。

然后大语言模型来了。

一边是刚好训练数据里有大量 Markdown 格式的文本,模型学会了用它表达结构。即训练数据上,那些技术博客论坛里「聪明人写的东西」大量是 Markdown。模型学到的不只是格式,还有「用 Markdown 写作 = 认真、结构化、专业」这个关联。

另一边是 Markdown 的结构信号非常局部化,一个标题只需要一个 #,一个列表只需要一个 -,** 出现就是加粗。模型也不需要看很远的上下文就能判断当前 token 的语义角色。

▲ 同样一篇文章,HTML 意味着繁多的标签、各种区块的分隔,以及样式控制等

对比 HTML 的标题和列表<h1> </h1> 或者 <li> </li> 要省得多,此外,HTML 的 <div class=”section”> 要等到 </div> 才闭合,语义跨度长,模型生成时需要「记住」更远的状态。对模型生成来说负担更重,出错概率更高。

所以无论是从大语言模型注意力机制的技术角度,还是 Token 经济学的角度,「能用 Markdown 就不用 HTML」在长文档、多轮对话、大量 API 调用的场景里,成了工程师和模型双方的偏好。

总结下来,Token 效率高、结构清晰、解析简单的核心价值,让模型天然爱 Markdown,它爱 Markdown 格式的输入,也爱 Markdown 格式的输出。

这种偏好在大语言模型训练时,也变得更加明显。

模型通过人类反馈强化学习 RLHF 的时候,标注员给高分的回答大概率是:有清晰标题、有分点列举、结构一目了然的回答。而这种视觉结构,在纯文本环境里就是 Markdown。

于是模型学到的奖励信号也是:用 Markdown 格式化 = 看起来更认真、更完整、更值得高分。即使问题根本不需要列表,模型也会倾向于加列表。

▲ 知名的 Markdown 编辑器 Typora

这大概也是为什么我们随便问 ChatGPT 一个问题,它都想给三个要点、加粗关键词、再来个小结。以及大多数时候,在 AI 的对话界面,复制 AI 的回答,粘贴到其他输入框,都会发现自动多了 #、**、—、等 Markdown 标识。

我们看到的每一条 AI 回复的文字消息,基本上都是以 Markdown 的格式在渲染。

为什么不是 PDF、Word、PPT

Markdown 好用,但是我们日常生活中使用的文档格式,大多还是 PDF 和 Word。老板发来一个文件,我丢给 AI 去处理,这个文件往往要比我直接复制粘贴,消耗更多的时间。

本质原因还是模型只认识 token,不认识文件。

大语言模型的输入,在进入模型之前必须先被转换成 token 序列。模型看不到「一个 PDF」,它看到的是 PDF 被解析出来的文本内容,然后再切成 token。所以哪种格式在解析成纯文本之后,损失的信息最少、引入的噪声最少,这种格式就是更好的格式。

▲ Claude 官方的 PDF Skill,需要调用专门的工具库才能实现 PDF 文件读取

PDF 设计的目标是「打印出来好看」,不是「机器好读」。在 PDF 内部存储的是每个字符的坐标位置,而不是文本的逻辑顺序。一个两列布局的 PDF,解析出来的文本顺序很可能是左列第一行、右列第一行、左列第二行、右列第二行,直接就完全乱掉。

表格更糟糕。PDF 里的表格通常是用绝对坐标定位的文本块,没有任何「这是一行」「这是一列」的语义信息。对 AI PDF 解析器来说,只能靠猜。

扫描版 PDF 就更不用说了,直接是图片,要先过 OCR 文本识别,而 OCR 的错误率直接进入模型上下文。

.docx 和 .pptx 本质上是 ZIP 压缩包,里面是一堆 XML(可扩展标记语言)文件。解析出来的原始内容里有大量样式标记,字体、颜色、段落间距、主题、修订记录,这些对模型理解内容毫无帮助,但会占用大量 token,稀释真正有用的信息。

对 PPT 来说,信息密度本来就低,一张幻灯片可能只有一句话、几个关键词,解析出来是碎片化的文本,没有上下文连接,模型很难重建完整的逻辑。

有人会说那 TXT 呢,其实 Markdown 和 Word 这类文本,本质上都可以转成 TXT 文档,它没有额外的噪声,但也没有任何结构信号。

模型很难定位到哪里是标题、哪里是列表、哪里是代码块、哪里是引用。对于长文档,还意味着模型要靠自然语言线索去猜结构,准确率不稳定。

▲ 图片由 AI 生成

类似的语言还有 JSON/XML,它们确实对机器更友好,但「机器」指的不是语言模型。

JSON 和 XML 是为程序解析设计的,键值对、层级结构、严格语法。传统软件读 JSON 很舒服,因为它可以直接 json.parse(),得到一个结构化对象。

而语言模型的「理解」是通过 token 之间的统计关联实现的。对语言模型来说,读 JSON 和读自然语言的方式是一样的,逐 token 处理,靠注意力机制建立关联。把这种严格结构化的格式喂给一个为模糊输入设计的系统,本身是一种错配。

Markdown 刚好在这两者之间,它是纯文本,但带有轻量结构信号。

▲ 部分工具像 Jina Reader,在网页 URL 前添加 r.jina.ai 前缀,就能将任何网页转换为 LLM 友好的 Markdown

解析 Markdown 不需要任何特殊工具,直接读文本就行,不会有 PDF 那种坐标混乱,不会有 Word 那种 XML 噪声。同时 # ** – 这些符号给了模型足够的结构线索,让它知道这段是标题、这段是列表、这段是代码。

这些符号本身就在 token 词表里,模型直接处理,不需要任何预处理步骤。

Markdown 也要过时了?

在 Claude Code 工程的那篇文章里,细数了 HTML 的几大优点。

▲ 图片由 AI 生成

信息密度更高,HTML 能传达的信息远比 Markdown 丰富。它能做基础的文档结构、标题格式,但它还能表示各种其他信息,像是 CSS 样式、SVG 图片、canvas 空间数据、流程图、img 标签插入图片等等。

他还提到,Claude 能处理越来越复杂的工作,它写的需求文档和计划也越来越长。而超过 100 行的 Markdown 文件根本读不下去,更别说让其他人去读。

但 HTML 文档的阅读体验就更轻松。Claude 可以用标签页、插图、链接等方式把结构组织得清晰易导航。它甚至能做到响应式布局,在不同设备上都能舒服地阅读。

在分享这点上,他也认为 HTML 的传播要比 Markdown 容易。 把 HTML 文件随便放到某个云平台上,发这个链接给朋友和发一份 Markdown 文档,一定是点开链接阅读的几率更大。

就像现在做报告,展示几十页的 PPT,不然直接打开一个网页。市面上常见的深度研究产品,在生成 PPT 时,所采用的格式也是从渲染 HTML 网页开始。

还有 HTML 的交互性,我们可以点击不同的按钮、使用滑块或旋钮来调节不同的信息展示。

在提到 Markdown 输出的 Token 要比 HTML 少时,以及更耗时间时,他说 HTML 可能比 Markdown 慢 2-4 倍,但觉得值得;而 HTML 带来的表达力提升、以及真正去读它的概率大幅提高,最终产出反而更好。

我们也尝试把 Thariq 这篇长文转成 HTML 的格式,相较于 X 推文的长截图,HTML 呈现的内容会对读者更友好。

针对 HTML 更合适给人阅读这点,文章所列的优点听起来确实 Markdown 很难做到,但直接把 HTML 描绘成新的 AI 通用语言,还为时尚早。

难道我们未来的会话,每一次都要等 AI 输出一个所谓样式精美、交互友好的网页吗?

我想我们和朋友闲聊的时候,不会希望它盛装打扮,更不会想他要化妆一小时,要我们原地等待他。

更不用说,在大多数用户接触到的 AI,即那些不针对编程、设计等特定领域的 AI,全部都是以对话的形式在交互,我们的会话或许并不需要一份精美的 HTML,现有的 Markdown 就已经足够了。

Claude Code 工程师这篇文章里也提到了 HTML 适用于哪些项目,例如要求 AI 生成一份详细的需求文档,包括规划项目和探索不同的设计方案、或是可视化代码审查和理解、制作交互原型,比如动画和动作效果、以及研究报告等使用场景。

而这些场景本来就是适合网页呈现的场景,用它来挑战 Markdown 的地位稍微有点胜之不武。

作者在最后得出的论点是 HTML 作为 AI 交付给人类的最终产物更好读。但他并没有主张用 HTML 作为 AI 的工作记忆或上下文格式,因为 Markdown 在这一领域目前就是所有 AI 的唯一解决方案。

Markdown 还是 AI 时代的 Word,那 Markdown 最终会走向哪里?

Markdown 是 AI 的工作语言,是上下文的载体,是 agent 之间传递信息的格式,但它可能不需要是用户最终看到的东西。HTML 或者未来某种更好的格式,是 Markdown 被渲染之后的界面。

HTML 挑战不用挑战 Markdown 的地位,它只需要承担补上 Markdown 从来就不需要承担的那个角色。

Markdown 可以是 HTML 的一部分,我们在网页上和 AI 聊天,AI 给我们的回复使用 Markdown,它此时是被嵌入到了 HTML 里。

未来的 Markdown 就像一块积木一样,它会被嵌入到 HTML、甚至是某种更精美的 XTML 语言里。

▲ 图片由 AI 生成

格式会一直往前走。HTML 是此刻的前台,但也只是此刻的。下一站可能是可交互的 3D 空间,再下一站可能是直接写进视网膜的信号流。

但无论前台换成什么,后台跑的还是 Markdown。它不会被取代,只会被遗忘。而在技术的世界里,被所有人遗忘,恰恰是一种格式最终胜利的方式。

每一代人都在争论下一个界面是什么。但真正活下来的,从来不是界面,是协议。

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

CocoaPods 正在退场,SwiftPM 才刚到第二章 - 肘子的 Swift 周报 #135

作者 Fatbobman
2026年5月11日 22:00

谷歌近期宣布,从下一个 Flutter 稳定版 3.44 开始,Swift Package Manager 将在默认路径上取代 CocoaPods,成为 iOS 和 macOS 应用的默认依赖管理器。CocoaPods 的 Trunk 仓库计划于 2026 年 12 月 2 日正式进入只读状态——这个时间点我们在 2024 年的周报中就讨论过了,但当 Flutter 真正开始在默认路径上用 SPM 替换 CocoaPods 时,还是引发了社区的广泛热议。

将 libsmb2 集成到 HarmonyOS ArkTS 项目

作者 xyccstudio
2026年5月8日 14:34

将 libsmb2 集成到 HarmonyOS ArkTS 项目

本文记录在鸿蒙媒体播放器项目 hmplayer 中集成 libsmb2 实现 SMB 网络文件浏览与播放的完整过程。libsmb2支持smb3协议。能够查看macos上的文件夹分享。配置macos分享的时候需要把选项中的window共享创建一个账号和密码。之后使用使用此账号密码进行连接。

整体架构

ArkTS 层 (Smb2Client.ets)
    ↓ NAPI 绑定 (libentry.so)
C/C++ 层 (napi_init.cpp)
    ↓ 静态链接
libsmb2 (thirdparty/libsmb2/arm64-v8a/lib/libsmb2.so.1)

ArkTS 通过 NAPI 调用 C++ 导出的函数,C++ 层直接调用 libsmb2 的 C API。整个模块编译为 libentry.so,在应用加载时自动注册。

第一步:放置预编译库

libsmb2 不需要在项目中从源码编译,直接放置预编译好的 arm64-v8a 架构的 .so 文件:

entry/src/main/cpp/thirdparty/libsmb2/arm64-v8a/
├── include/smb2/
│   ├── libsmb2.h
│   ├── smb2.h
│   ├── smb2-errors.h
│   ├── libsmb2-raw.h
│   └── libsmb2-dcerpc*.h
├── lib/
│   ├── libsmb2.so.6.1.0
│   ├── libsmb2.so.1 -> libsmb2.so.6.1.0
│   ├── libsmb2.so -> libsmb2.so.1
│   └── cmake/libsmb2/
└── lib/pkgconfig/

第二步:配置 CMake

entry/src/main/cpp/CMakeLists.txt 中添加:

cmake_minimum_required(VERSION 3.4.1)
project(libsmb2project)

# 应用主库,包含 NAPI 绑定
add_library(entry SHARED napi_init.cpp)

# 链接 NAPI 运行时和日志库
target_link_libraries(entry PUBLIC libace_napi.z.so libhilog_ndk.z.so)

# 引入 libsmb2 预编译库
set(LIBSMB2_DIR ${CMAKE_CURRENT_SOURCE_DIR}/thirdparty/libsmb2/${OHOS_ARCH})
set(LIBSMB2_LIB ${LIBSMB2_DIR}/lib/libsmb2.so.1)

target_link_libraries(entry PRIVATE ${LIBSMB2_LIB})
target_include_directories(entry PRIVATE ${LIBSMB2_DIR}/include)

${OHOS_ARCH} 由 DevEco Studio 构建时自动注入,值为 arm64-v8a

第三步:配置 ABI 过滤

entry/build-profile.json5 中指定只构建 arm64-v8a

{
  externalNativeOptions: {
    path: './src/main/cpp/CMakeLists.txt',
    abiFilters: ['arm64-v8a'],
    arguments: '',
    cppFlags: '',
  },
}

第四步:编写 NAPI 绑定

entry/src/main/cpp/napi_init.cpp 是核心绑定文件,将 libsmb2 的 C API 暴露给 ArkTS。

模块注册

#include <napi/native_api.h>

static napi_module entryModule = {
    .nm_version = 1,
    .nm_flags = 0,
    .nm_filename = nullptr,
    .nm_register_func = RegisterEntryModule,   // 你的注册函数
    .nm_modname = "entry",
    .nm_priv = nullptr,
    .reserved = {0},
};

extern "C" __attribute__((constructor)) void RegisterEntryModule(void)
{
    napi_module_register(&entryModule);
}

__attribute__((constructor)) 保证共享库加载时自动执行注册。

全局状态管理

Native 层使用全局变量维护单一连接:

static smb2_context* g_smb2_ctx = nullptr;
static smb2dir*      g_smb2_dir = nullptr;
static smb2fh*       g_smb2_fh  = nullptr;

同一时间只允许一个 SMB 连接,打开新文件时自动关闭旧的文件句柄。

导出函数示例

每个 NAPI 函数都是 libsmb2 C API 的薄封装,负责 napi_value 与 C 类型之间的转换:

// 初始化上下文
static napi_value InitContext(napi_env env, napi_callback_info info)
{
    // ...
    g_smb2_ctx = smb2_init_context();
    if (!g_smb2_ctx) {
        napi_create_int32(env, -1, &result);
        return result;
    }
    napi_create_int32(env, 0, &result);
    return result;
}

// 连接共享
static napi_value ConnectShare(napi_env env, napi_callback_info info)
{
    size_t argc = 4;
    napi_value args[4];
    napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);

    char server[256], share[256], user[256], password[256];
    // ... 从 napi_value 提取字符串

    int ret = smb2_connect_share(g_smb2_ctx, server, share, user, password);
    // ...
}

// 读取文件(随机访问)
static napi_value ReadFile(napi_env env, napi_callback_info info)
{
    size_t argc = 2;
    napi_value args[2];
    napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);

    int64_t offset, size;
    // ... 提取参数

    uint8_t* buf = new uint8_t[size];
    int n = smb2_pread(g_smb2_ctx, g_smb2_fh, buf, size, offset);

    // 将数据拷贝到 ArrayBuffer 返回给 ArkTS
    void* data;
    napi_create_arraybuffer(env, n, &data, &ab);
    memcpy(data, buf, n);
    delete[] buf;
    return ab;
}

所有导出函数在 RegisterEntryModule 中统一注册:

static napi_value RegisterEntryModule(napi_env env, napi_value exports)
{
    napi_property_descriptor desc[] = {
        { "initContext",        nullptr, InitContext,        nullptr, nullptr, nullptr, napi_default, nullptr },
        { "destroyContext",     nullptr, DestroyContext,     nullptr, nullptr, nullptr, napi_default, nullptr },
        { "setUser",            nullptr, SetUser,            nullptr, nullptr, nullptr, napi_default, nullptr },
        { "setPassword",        nullptr, SetPassword,        nullptr, nullptr, nullptr, napi_default, nullptr },
        { "setDomain",          nullptr, SetDomain,          nullptr, nullptr, nullptr, napi_default, nullptr },
        { "setAuthentication",  nullptr, SetAuthentication,  nullptr, nullptr, nullptr, napi_default, nullptr },
        { "connectShare",       nullptr, ConnectShare,       nullptr, nullptr, nullptr, napi_default, nullptr },
        { "disconnectShare",    nullptr, DisconnectShare,    nullptr, nullptr, nullptr, napi_default, nullptr },
        { "openDir",            nullptr, OpenDir,            nullptr, nullptr, nullptr, napi_default, nullptr },
        { "closeDir",           nullptr, CloseDir,           nullptr, nullptr, nullptr, napi_default, nullptr },
        { "readDir",            nullptr, ReadDir,            nullptr, nullptr, nullptr, napi_default, nullptr },
        { "getError",           nullptr, GetError,           nullptr, nullptr, nullptr, napi_default, nullptr },
        { "openFile",           nullptr, OpenFile,           nullptr, nullptr, nullptr, napi_default, nullptr },
        { "closeFile",          nullptr, CloseFile,          nullptr, nullptr, nullptr, napi_default, nullptr },
        { "getFileSize",        nullptr, GetFileSize,        nullptr, nullptr, nullptr, napi_default, nullptr },
        { "readFile",           nullptr, ReadFile,           nullptr, nullptr, nullptr, napi_default, nullptr },
    };
    napi_define_properties(env, exports, sizeof(desc) / sizeof(desc[0]), desc);
    return exports;
}

第五步:声明 TypeScript 类型

entry/src/main/cpp/types/libentry/index.d.ts 中声明类型,让 ArkTS 能获得完整的类型提示:

export interface Smb2DirEntry {
  name: string
  type: number
  isDirectory: boolean
}

export const SMB2_SEC_UNDEFINED: number
export const SMB2_SEC_NTLMSSP: number
export const SMB2_SEC_KRB5: number

export function initContext(): number
export function destroyContext(): number
export function setUser(user: string): number
export function setPassword(password: string): number
export function setDomain(domain: string): number
export function setAuthentication(method: number): number
export function connectShare(
  server: string,
  share: string,
  user: string,
  password: string
): number
export function disconnectShare(): number
export function openDir(path: string): number
export function closeDir(): number
export function readDir(): Smb2DirEntry[]
export function getError(): string
export function openFile(path: string): number
export function closeFile(): number
export function getFileSize(): number
export function readFile(offset: number, size: number): ArrayBuffer

对应的 oh-package.json5

{
  name: 'libentry.so',
  types: './index.d.ts',
  version: '1.0.0',
}

第六步:ArkTS 封装层

在 ArkTS 侧通过 import libentry from 'libentry.so' 导入原生模块,封装为易用的类。

Smb2Client

import libentry from 'libentry.so'

export class Smb2Client {
  private connected: boolean = false

  init(): number {
    return libentry.initContext()
  }

  destroy(): number {
    this.connected = false
    return libentry.destroyContext()
  }

  setUser(user: string): number {
    return libentry.setUser(user)
  }

  setPassword(password: string): number {
    return libentry.setPassword(password)
  }

  setDomain(domain: string): number {
    return libentry.setDomain(domain)
  }

  setAuthentication(method: number): number {
    return libentry.setAuthentication(method)
  }

  connect(
    server: string,
    share: string,
    user: string,
    password: string
  ): number {
    const ret = libentry.connectShare(server, share, user, password)
    if (ret === 0) {
      this.connected = true
    }
    return ret
  }

  disconnect(): number {
    this.connected = false
    return libentry.disconnectShare()
  }

  openDir(path: string): number {
    return libentry.openDir(path)
  }

  closeDir(): number {
    return libentry.closeDir()
  }

  readDir(): libentry.Smb2DirEntry[] {
    return libentry.readDir()
  }

  getError(): string {
    return libentry.getError()
  }

  openFile(path: string): number {
    return libentry.openFile(path)
  }

  closeFile(): number {
    return libentry.closeFile()
  }

  getFileSize(): number {
    return libentry.getFileSize()
  }

  readFile(offset: number, size: number): ArrayBuffer {
    return libentry.readFile(offset, size)
  }
}

SmbFileCache(文件缓存下载)

SMB 文件不能直接作为视频播放器的数据源,需要先下载到本地缓存。SmbFileCache 通过分块读取 + 并发写入实现高效下载:

export class SmbFileCache {
  private client: Smb2Client
  private readonly CHUNK_SIZE = 4 * 1024 * 1024 // 4MB

  async download(
    remotePath: string,
    localPath: string,
    onProgress?: (progress: number, speed: string) => void
  ): Promise<string> {
    // 1. 打开远程文件
    this.client.openFile(remotePath)
    const totalSize = this.client.getFileSize()

    // 2. 分块读取,通过 ConcurrentFileDownloader 写入本地文件
    let downloaded = 0
    let startTime = Date.now()

    while (downloaded < totalSize) {
      const chunkSize = Math.min(this.CHUNK_SIZE, totalSize - downloaded)
      const data = this.client.readFile(downloaded, chunkSize)

      // 并发写入磁盘(不阻塞主线程)
      await ConcurrentFileDownloader.writeChunk(localPath, downloaded, data)

      downloaded += chunkSize

      // 回调进度
      if (onProgress) {
        const elapsed = (Date.now() - startTime) / 1000
        const speed = downloaded / elapsed / 1024
        onProgress(downloaded / totalSize, `${speed.toFixed(1)} KB/s`)
      }
    }

    // 3. 关闭文件句柄
    this.client.closeFile()
    return localPath
  }
}

第七步:UI 层集成

SmbEntry 类型

目录条目类型与 NAPI 声明一致:

interface SmbEntry {
  name: string
  type: number
  isDirectory: boolean
}

连接流程

pageSmbBrowser.ets 中,页面组件的生命周期管理 SMB 连接:

@Entry
@Component
struct PageSmbBrowser {
  @State client: Smb2Client = new Smb2Client();
  @State entries: SmbEntry[] = [];
  @State currentPath: string = '/';
  @State downloadProgress: number = 0;
  @State downloadSpeed: string = '';
  @State isDownloading: boolean = false;

  aboutToAppear() {
    this.client.init();
    this.client.setUser('admin');
    this.client.setPassword('123456');
    this.client.setDomain('WORKGROUP');

    const ret = this.client.connect('192.168.1.100', 'Public', 'admin', '123456');
    if (ret === 0) {
      this.loadDirectory('/');
    }
  }

  aboutToDisappear() {
    this.client.disconnect();
    this.client.destroy();
  }

  loadDirectory(path: string) {
    const ret = this.client.openDir(path);
    if (ret === 0) {
      const raw = this.client.readDir();
      // 过滤 . 和 ..,目录优先排序
      this.entries = raw
        .filter(e => e.name !== '.' && e.name !== '..')
        .sort((a, b) => {
          if (a.isDirectory && !b.isDirectory) return -1;
          if (!a.isDirectory && b.isDirectory) return 1;
          return a.name.localeCompare(b.name);
        });
      this.client.closeDir();
    }
  }
}

文件操作

// 点击目录:进入子目录
onDirClick(entry: SmbEntry) {
  const newPath = this.currentPath === '/' ? `/${entry.name}` : `${this.currentPath}/${entry.name}`;
  this.currentPath = newPath;
  this.loadDirectory(newPath);
}

// 点击媒体文件:下载到缓存后播放
onMediaClick(entry: SmbEntry) {
  const remotePath = this.currentPath === '/' ? `/${entry.name}` : `${this.currentPath}/${entry.name}`;
  const localPath = `/data/storage/el2/base/cache/smb/${entry.name}`;

  this.isDownloading = true;
  const cache = new SmbFileCache(this.client);
  cache.download(remotePath, localPath, (progress, speed) => {
    this.downloadProgress = progress;
    this.downloadSpeed = speed;
  }).then((path) => {
    // 播放本地缓存文件(调用导航跳转到播放器页面)
    this.isDownloading = false;
    // pushPath({ name: PageName.videoPlayer, param: { uri: path } });
  });
}

// 返回上级目录
onBack() {
  if (this.currentPath === '/') return;
  const parts = this.currentPath.split('/').filter(p => p.length > 0);
  parts.pop();
  this.currentPath = parts.length > 0 ? '/' + parts.join('/') : '/';
  this.loadDirectory(this.currentPath);
}

API 列表

集成的 16 个 NAPI 函数覆盖了 SMB 文件浏览与读取的完整流程:

函数 对应 libsmb2 API 说明
initContext() smb2_init_context() 创建 SMB 上下文
destroyContext() smb2_destroy_context() 销毁上下文
setUser(user) smb2_set_user() 设置用户名
setPassword(password) smb2_set_password() 设置密码
setDomain(domain) smb2_set_domain() 设置域名
setAuthentication(method) smb2_set_authentication() 认证方式(0=自动, 1=NTLM, 2=Kerberos)
connectShare(server, share, user, password) smb2_connect_share() 连接 SMB 共享
disconnectShare() smb2_disconnect_share() 断开连接
openDir(path) smb2_opendir() 打开远程目录
closeDir() smb2_closedir() 关闭目录句柄
readDir() smb2_readdir() 循环至 NULL 读取全部目录条目
getError() smb2_get_error() 获取最近一次错误信息
openFile(path) smb2_open(path, O_RDONLY) 打开文件(只读)
closeFile() smb2_close() 关闭文件句柄
getFileSize() smb2_fstat() 获取文件大小
readFile(offset, size) smb2_pread() 按偏移量读取指定字节,返回 ArrayBuffer

关键注意事项

  1. 单一连接限制:Native 层使用全局变量 g_smb2_ctxg_smb2_dirg_smb2_fh,同一时间只能维持一个 SMB 连接、一个目录句柄、一个文件句柄。对于单页面浏览场景足够,如需并发连接需重构为句柄池模式。

  2. 只读文件访问openFile 固定使用 O_RDONLY 标志,不支持写操作。

  3. 同步 API:绑定只使用了 libsmb2 的同步接口(smb2_connect_sharesmb2_pread 等),未接入 libsmb2 的异步事件循环(smb2_get_fd/smb2_service)。在鸿蒙的 TaskPool 中执行可避免阻塞 UI 主线程。

  4. 权限声明:在 module.json5 中需要声明 ohos.permission.INTERNETohos.permission.GET_NETWORK_INFO

  5. ABI 支持:当前仅构建 arm64-v8a。如需支持 armeabi-v7ax86_64,需交叉编译对应架构的 libsmb2 并添加到 abiFilters 中。

参考文档

参考文档:参考文档

Taro小程序生成分享海报解决方案

作者 朱良
2026年5月5日 15:27

场景:在Taro开发中,在商品/职位/文章的详情页需要转发生成一个png海报,如果在Web开发中,直接html+css写海报样式,Vue/Teact中通过Props传入详情信息就可以,然后定位到屏幕视口外,通过html2canvas生成和导出海报样式,但是在小程序中,无法使用html2canvas

尝试寻找第三方库

wxml2canvas/taro-wxml2canvas可以吗,我使用过,证明是不可行的,且不说taro导入第三方原生组件十分麻烦,在Taro4中,和Taro3,Taro2基本经历过多次更新,wxml2canvas/taro-wxml2canvas库已经很久没有维护,实测不可行。

taro-plugin-canvas和taro3-canvas之类的库长时间不更新无法使用;

taro-html-parser和wxParse库也是长时间不更新无法使用;

mp-html这个还在维护库只支持uniapp和原生小程序;

似乎Taro被市场抛弃了,一个纯前端解决生成分享海报的库都没有吗?

尝试Canvas绘制

其实海报就是一个png图片,canvas一样可以绘制,Taro是支持canvas的,但canvas绘制的海报在多文字处理上非常麻烦,样式调整费劲,其实先写html,让AI转为canvas代码,也是还原度低,对于复杂海报样式很难实现,更难二次调整;

尝试SVG绘制

SVG是在浏览器上可行的,UI设计师出稿一个SVG模板,详情页信息(文字/图片)直接拼接/导出到SVG中,生成海报图片,但是小程序不支持SVG,所以依然不可行;

最终解决方案

使用Taro原生的Snapshot组件,只需要写原生的Taro代码,会被截图为画布,渲染结果导出成图片,需要局部开启Skyline模式,但是总算是实现了浏览器中Html2Canvas的效果,参考文档 docs.taro.zone/docs/apis/s…

参考代码

sharePoster.tsx

import { useRef, useCallback } from 'react'
import Taro from '@tarojs/taro'
import { View, Text, Button, Image } from '@tarojs/components'
import './poster.css'

export default function PosterPage() {
  // 1. 创建 ref 获取海报容器节点(Skyline 节点)
  const posterRef = useRef<View>(null)
  // 2. 声明 Snapshot 实例
  let snapshotInstance: Taro.Snapshot | null = null

  // 🔥 核心:生成海报(截图)
  const createPoster = useCallback(async () => {
    try {
      // 校验节点
      if (!posterRef.current) {
        Taro.showToast({ title: '节点不存在', icon: 'none' })
        return
      }

      // 1. 初始化 Snapshot 实例(官方标准用法)
      snapshotInstance = Taro.createSnapshot()

      // 2. 获取 Skyline 节点的 ID(必须通过 ref 获取)
      const nodeId = posterRef.current._nodeId

      // 3. 执行截图(核心 API)
      const res = await snapshotInstance.takeSnapshot({
        nodeId, // 要截图的 Skyline 节点 ID
        // 可选:自定义截图尺寸/质量
        quality: 1,
        type: 'png'
      })

      // 4. 截图成功:res.tempFilePath 是海报临时路径
      const posterPath = res.tempFilePath
      console.log('✅ 海报生成成功:', posterPath)

      // 可选:预览海报 / 保存到相册
      Taro.showToast({ title: '生成成功', icon: 'success' })
      Taro.previewImage({ urls: [posterPath] })

    } catch (err) {
      console.error('❌ 海报生成失败:', err)
      Taro.showToast({ title: '生成失败', icon: 'none' })
    }
  }, [])

  return (
    <View className='page-container'>
      {/* 🔥 海报容器:Skyline 渲染节点,ref 绑定 */}
      <View ref={posterRef} className='poster-box'>
        {/* 这里写Taro原生代码,海报内容(可自定义:图片、文字、头像等) */}
      </View>

      {/* 生成按钮 */}
      <Button className='create-btn' onClick={createPoster}>
        生成海报
      </Button>
    </View>
  )
}

需要在专门的分享页开启skyline xxx.config.ts

export default {
  navigationBarTitleText: '生成分享海报',
  skyline: { enable: true },
  defaultRenderEngine: 'skyline',
  disableScroll: true
}

建议在详情页点击分享->生成分享海报按钮时,跳转该页面,生成完成后携带res.tempFilePath临时路径 返回详情页,然后展示和下载海报图片。因为skyline模式的无法使用第三方UI组件,建议单独独立为一个局部页面。

HarmonyOS AbilityStage 实战:别把启动参数散落在每个页面里

作者 李游Leo
2026年5月5日 15:07

鸿蒙应用做到后面,真正让人头疼的,往往不是某个页面写得丑,也不是某个按钮样式没调好,而是入口越来越多以后,启动逻辑开始乱。

桌面图标能进来,服务卡片能进来,通知能进来,Deep Link 能进来,应用内部还可能用 Want 拉起另一个 UIAbility。第一版代码一般都挺朴素:在首页 aboutToAppear 里读一下参数,判断要不要跳详情页。刚开始没问题,甚至看起来挺清爽。

但需求一多,首页就很容易变成“入口垃圾桶”。

这里判断通知来源,那里判断服务卡片参数,后面又补一个外部链接解析。冷启动的时候还能凑合,二次拉起、回前台、横竖屏切换、任务栈恢复一来,问题就开始变得有点玄学了。

我之前见过一个挺典型的线上问题:用户从服务卡片点进来,本来应该打开某个订单详情页,结果偶尔落到首页;用户从外部链接再次拉起应用,页面没刷新;还有更隐蔽的,应用已经在后台了,新的 Want 进来以后,全局初始化又跑了一遍,监听注册了两次,后面同一个事件回调两遍。

查日志的时候也挺难受。每个页面都觉得自己只是“顺手处理一下入口参数”,最后谁也说不清这次启动到底是桌面启动、卡片启动,还是二次拉起。

这种问题不能靠再加几个 if 硬顶。Stage 模型下,AbilityStage、Want、UIAbility 这条链路本来就应该承担启动治理的职责。只是很多项目写着写着,把它们当成了“系统自动生成的模板文件”,真正的业务入口反而全塞到页面里了。

image.png

AbilityStage / Want / UIAbility,别分开看

单独看这几个概念,其实都不复杂。

AbilityStage 是 Module 级别的组件管理器,HAP 首次加载时会创建它。UIAbility 是带界面的应用组件,负责创建、销毁、前后台切换这些生命周期。Want 是组件之间传递信息的载体,启动目标、参数、action、uri 这些东西都可以从里面拿。

但工程里真正容易出问题的地方,不是“某个回调怎么写”,而是边界没划清。

我一般会这么分:

  • AbilityStage 管进程级、模块级的东西,比如轻量初始化、Specified 启动模式分流、全局依赖准备。
  • Want 只当入口信息,进来以后尽快转成业务能理解的结构。
  • UIAbility 管窗口、生命周期和启动载荷注入。
  • ArkUI 页面只消费归一后的业务参数,不直接解析原始 Want。

这几条边界看着有点啰嗦,但真到项目里很有用。

后面新增通知入口、服务卡片入口、Deep Link 入口时,不需要每个页面跟着改。入口逻辑集中在入口层,页面只关心“我要展示什么业务状态”。这才比较像一个能长期维护的结构。

先把原始 Want 收敛成 LaunchPayload

很多启动混乱,根源就是页面直接读 Want。

页面一旦开始知道太多入口细节,就会慢慢变成半个路由中心。今天读 scene,明天读 from,后天再补一个 uri,最后首页里一堆参数判断,谁也不敢动。

我更习惯定义一个中间结构,叫 LaunchPayload。它不追求把 Want 的所有字段都复刻一遍,只留下业务真正需要的东西。

// common/launch/LaunchPayload.ets
export enum LaunchScene {
  NORMAL = 'normal',
  CARD = 'card',
  NOTIFICATION = 'notification',
  DEEP_LINK = 'deep_link',
  INTERNAL = 'internal'
}

export interface LaunchPayload {
  scene: LaunchScene
  targetPage: string
  bizId?: string
  uri?: string
  from?: string
  rawAction?: string
  extras: Record<string, string>
  receivedAt: number
}

这里有个小取舍:extras 我只放字符串。

不是说 Want 里不能带别的类型,而是启动参数最好别变成一个“万能对象”。入口传来的东西越杂,页面兜底越麻烦。真要复杂对象,建议传 id,再让业务层去查详情。

启动参数要负责的是“把用户带到哪”,不是“把整个业务现场都搬进来”。这句话挺重要,很多入口混乱都是从这里开始的。

写一个 Want 解析器,别让页面自己猜

下面这个 LaunchPayloadParser 就是专门干脏活的。

它负责把不同来源的 Want 参数,统一整理成业务可读的结构。页面拿到的不是一坨原始参数,而是一份已经归一过的启动载荷。

// common/launch/LaunchPayloadParser.ets
import { Want } from '@kit.AbilityKit'
import { LaunchPayload, LaunchScene } from './LaunchPayload'

export class LaunchPayloadParser {
  static parse(want: Want | undefined): LaunchPayload {
    const params = want?.parameters ?? {}
    const uri = want?.uri ?? ''
    const action = want?.action ?? ''

    const scene = this.parseScene(params, uri, action)
    const bizId = this.readString(params, 'bizId')
    const from = this.readString(params, 'from')

    return {
      scene,
      targetPage: this.resolveTargetPage(scene, bizId, uri),
      bizId,
      uri,
      from,
      rawAction: action,
      extras: this.pickSafeExtras(params),
      receivedAt: Date.now()
    }
  }

  private static parseScene(params: Record<string, Object>, uri: string, action: string): LaunchScene {
    const scene = this.readString(params, 'scene')

    if (scene === 'card') {
      return LaunchScene.CARD
    }

    if (scene === 'notification') {
      return LaunchScene.NOTIFICATION
    }

    if (uri.length > 0) {
      return LaunchScene.DEEP_LINK
    }

    if (action.length > 0) {
      return LaunchScene.INTERNAL
    }

    return LaunchScene.NORMAL
  }

  private static resolveTargetPage(scene: LaunchScene, bizId?: string, uri?: string): string {
    if (scene === LaunchScene.DEEP_LINK && uri) {
      return this.resolveDeepLink(uri)
    }

    if (bizId && bizId.length > 0) {
      return 'pages/Detail'
    }

    return 'pages/Home'
  }

  private static resolveDeepLink(uri: string): string {
    // 这里只做简单示例。
    // 真实项目里建议做白名单解析,别让外部 uri 任意指定页面路径。
    if (uri.includes('/detail')) {
      return 'pages/Detail'
    }

    if (uri.includes('/search')) {
      return 'pages/Search'
    }

    return 'pages/Home'
  }

  private static pickSafeExtras(params: Record<string, Object>): Record<string, string> {
    const allowList: string[] = ['tab', 'keyword', 'source']
    const extras: Record<string, string> = {}

    allowList.forEach((key: string) => {
      const value = this.readString(params, key)
      if (value !== undefined) {
        extras[key] = value
      }
    })

    return extras
  }

  private static readString(params: Record<string, Object>, key: string): string | undefined {
    const value = params[key]
    return typeof value === 'string' ? value : undefined
  }
}

这段代码看起来确实有点啰嗦,但它救命的地方也就在这。

所有入口先过一层白名单,不允许外部参数直接控制内部页面路径;所有参数先转成可控结构,不让页面到处写 want.parameters?.xxx。项目越大,这种“看起来多一层”的代码越值钱。

我自己比较怕那种“先凑合一下”的入口代码。因为入口一旦散了,后面不是不好重构,是没人敢重构。用户从哪里进来、带了什么参数、应该落到哪个页面,全都藏在几个页面生命周期里,查一次问题能把人查麻。

AbilityStage:只做进程级初始化和启动分流

AbilityStage 很容易被误用。

有些项目会把一堆业务初始化都丢进去:数据库、网络、埋点、用户信息、远程配置,全塞上。冷启动一慢,大家又开始怀疑系统回调慢,或者怀疑首屏性能不行。其实很多时候,是自己把太重的东西放错地方了。

我的建议比较保守:AbilityStage 只做轻量、必要、进程级的事情。

比如日志初始化、依赖容器准备、Specified 启动模式的 key 分流。需要 IO、需要用户态、需要网络的初始化,不要一股脑压在这里。

// entry/src/main/ets/entryability/MyAbilityStage.ets
import { AbilityStage, Want } from '@kit.AbilityKit'
import { hilog } from '@kit.PerformanceAnalysisKit'
import { LaunchPayloadParser } from '../common/launch/LaunchPayloadParser'

export default class MyAbilityStage extends AbilityStage {
  onCreate(): void {
    // 这里适合做轻量级、进程级准备。
    // 不建议在这里做耗时网络请求,也别依赖页面上下文。
    hilog.info(0x0000, 'AppStage', 'AbilityStage onCreate')
  }

  onAcceptWant(want: Want): string {
    // Specified 启动模式下,系统会通过这个 key 决定复用哪个 UIAbility 实例。
    // key 设计要稳定,不要把时间戳这种随机值塞进去。
    const payload = LaunchPayloadParser.parse(want)

    if (payload.bizId && payload.bizId.length > 0) {
      return `detail_${payload.bizId}`
    }

    return 'main'
  }
}

onAcceptWant 这块很容易写错。

我见过有人为了“保证每次都是新的”,直接返回时间戳。短期看,好像解决了页面不刷新的问题;长期看,其实是把实例复用搞乱了。

Specified 模式要的不是“每次都新开”,而是“同一类业务复用同一个目标实例”。key 的粒度要跟业务场景一致。比如详情页按 id 分流,主入口统一回到 main。别为了省事,把 key 写成一个随机数,那后面任务栈和实例管理都会跟着乱。

UIAbility:冷启动和二次拉起要走同一套逻辑

UIAbility 的生命周期更贴近业务。

用户冷启动时会走 onCreate,窗口创建时走 onWindowStageCreate;应用已有实例再次被拉起时,常见场景会走 onNewWant。如果只在 onCreate 里处理参数,二次拉起就很容易漏。

我一般会在 UIAbility 里保留一个当前启动载荷,然后用 LocalStorage 注入页面。页面不直接碰 Want。

// entry/src/main/ets/entryability/EntryAbility.ets
import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit'
import { window } from '@kit.ArkUI'
import { hilog } from '@kit.PerformanceAnalysisKit'
import { LaunchPayload } from '../common/launch/LaunchPayload'
import { LaunchPayloadParser } from '../common/launch/LaunchPayloadParser'

export default class EntryAbility extends UIAbility {
  private storage: LocalStorage = new LocalStorage()
  private latestPayload?: LaunchPayload

  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    this.latestPayload = LaunchPayloadParser.parse(want)
    this.storage.setOrCreate('launchPayload', this.latestPayload)

    hilog.info(0x0000, 'EntryAbility',
      `onCreate scene=${this.latestPayload.scene}, target=${this.latestPayload.targetPage}`)
  }

  onWindowStageCreate(windowStage: window.WindowStage): void {
    // 页面首次加载时,把归一后的启动载荷注入进去。
    windowStage.loadContent('pages/Home', this.storage, (err) => {
      if (err.code) {
        hilog.error(0x0000, 'EntryAbility',
          `loadContent failed, code=${err.code}, message=${err.message}`)
        return
      }

      hilog.info(0x0000, 'EntryAbility', 'loadContent success')
    })
  }

  onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    // 已有实例被再次拉起时,不要重复跑全局初始化。
    // 这里只更新启动载荷,让页面或路由层消费。
    this.latestPayload = LaunchPayloadParser.parse(want)
    this.storage.setOrCreate('launchPayload', this.latestPayload)

    hilog.info(0x0000, 'EntryAbility',
      `onNewWant scene=${this.latestPayload.scene}, target=${this.latestPayload.targetPage}`)
  }

  onForeground(): void {
    // 恢复轻量资源,例如刷新当前会话状态。
    // 不建议在这里重复解析启动参数。
    hilog.info(0x0000, 'EntryAbility', 'onForeground')
  }

  onBackground(): void {
    // 暂停耗时任务、保存必要状态。
    hilog.info(0x0000, 'EntryAbility', 'onBackground')
  }

  onDestroy(): void {
    // 取消监听、释放 UIAbility 级资源。
    hilog.info(0x0000, 'EntryAbility', 'onDestroy')
  }
}

这段的重点不是 loadContent,而是 onCreateonNewWant 共用同一个解析器。

冷启动和二次拉起不应该分裂成两套业务规则。你今天忘了在 onNewWant 补一个参数,明天就会遇到那种特别烦的现象:应用杀掉后正常,后台唤起异常;从桌面进来正常,从通知进来异常。

这类问题通常不好测,因为测试同学一旦把应用杀掉重进,问题就消失了。

页面只消费 LaunchPayload,不碰原始 Want

页面里可以用 @LocalStorageProp 拿到启动载荷。至于它来自桌面、卡片还是 Deep Link,页面不需要知道太多。

// entry/src/main/ets/pages/Home.ets
import { LaunchPayload, LaunchScene } from '../common/launch/LaunchPayload'

@Entry
@Component
struct Home {
  @LocalStorageProp('launchPayload') launchPayload?: LaunchPayload

  @State tip: string = '正常进入首页'

  aboutToAppear(): void {
    this.consumeLaunchPayload(this.launchPayload)
  }

  onPageShow(): void {
    // 从后台回到前台时,页面可做轻量刷新。
    // 不建议在这里重新猜测启动来源。
  }

  private consumeLaunchPayload(payload?: LaunchPayload): void {
    if (!payload) {
      return
    }

    if (payload.scene === LaunchScene.CARD) {
      this.tip = `从服务卡片进入,业务ID:${payload.bizId ?? '无'}`
      return
    }

    if (payload.scene === LaunchScene.DEEP_LINK) {
      this.tip = `从外部链接进入:${payload.uri ?? ''}`
      return
    }

    if (payload.bizId) {
      this.tip = `准备打开详情:${payload.bizId}`
      return
    }

    this.tip = '正常进入首页'
  }

  build() {
    Column({ space: 16 }) {
      Text('HarmonyOS 启动治理示例')
        .fontSize(22)
        .fontWeight(FontWeight.Bold)

      Text(this.tip)
        .fontSize(15)
        .fontColor('#666666')

      if (this.launchPayload?.targetPage === 'pages/Detail') {
        Button('进入详情')
          .onClick(() => {
            // 项目里可以交给统一 RouterService,
            // 不建议在每个页面散落路由拼接。
          })
      }
    }
    .padding(24)
    .width('100%')
  }
}

这里有个问题经常有人问:为什么不在 UIAbility 里直接路由到详情页?

可以,但要看项目的路由方案。

如果你的登录态、弹窗恢复、页面栈管理、Tab 状态都在页面层或者 RouterService 里,UIAbility 直接跳详情页,有时候反而会绕过业务状态。我的做法是,UIAbility 负责把“启动意图”送到页面,真正的业务路由交给应用内部的 RouterService。

这样页面栈归页面,入口归入口,边界比较清楚。后面要改路由策略,也不用去生命周期回调里翻一堆代码。

生命周期不是背回调顺序,而是定职责

UIAbility 启动到前台时,会触发 onCreate()onWindowStageCreate()onForeground() 这一类生命周期回调。文档顺序看懂不难,难的是每个回调里该放什么、不该放什么。

image.png

我通常按下面这个口径拆:

onCreate:读取 Want,生成 LaunchPayload,准备 UIAbility 级状态。别在这里直接操作还没创建的窗口。

onWindowStageCreate:加载页面,注入 LocalStorage,绑定窗口相关逻辑。窗口级别的东西放这里,不要提前。

onNewWant:已有实例再次被拉起时更新启动意图。这里不要重复初始化全局服务,也不要重复注册监听。

onForeground:应用回到前台,恢复轻量资源,比如刷新会话、恢复播放按钮状态。不要把它当第二个 onCreate

onBackground:暂停耗时任务,保存必要状态。能停的就停,尤其是轮询、定位、长连接这类逻辑。

onDestroy:取消监听、释放资源、打点收尾。不要假设它每次都一定按你期望的时机触发,但该写的清理还是要写。

职责分清以后,很多“偶发问题”就不再玄学了。

比如二次拉起没刷新,就去看 onNewWant;前后台切换重复初始化,就查 onForeground;窗口相关状态异常,就去看 onWindowStageCreate。至少排查方向是明确的,不至于在首页、详情页、路由工具类之间来回翻。

常见坑位:这些地方真的容易埋雷

1. 首页承担了太多入口职责

首页读 Want、首页解析 URI、首页判断通知、首页处理卡片参数,短期确实快,长期基本一定乱。首页是 UI,不是入口网关。

这个坑很多项目都会踩,因为第一版最方便的地方就是首页。但方便不是没有代价,只是代价晚点来。

2. onNewWant 忘了处理

这类 bug 很烦:冷启动正常,后台再次拉起异常;杀掉应用再试,又正常了。

原因往往是只在 onCreate 解析了 Want,已有实例二次拉起时没有更新业务载荷。开发自测时如果习惯每次都杀进程,很容易漏掉。

3. Specified key 设计太随意

onAcceptWant 返回的 key 应该稳定、有业务含义。

随机 key 会让实例复用不可控;key 粒度太粗,会导致不同业务入口抢同一个实例。这个地方别偷懒,最好一开始就按业务场景定规则。

4. 外部参数直接控制页面路径

Deep Link 或外部 Want 里带一个 path,然后你直接 router 到对应页面,这个写法很危险。

至少要做白名单映射。外部参数只能表达意图,不能拿到内部路由的完全控制权。尤其是对外开放的链接入口,更不能相信传进来的每一个字段。

5. 前后台切换重复初始化

onForeground 不是重启。

回前台时做轻量恢复可以,别把登录初始化、数据库初始化、全局监听注册再跑一遍。重复初始化这种问题前期不明显,后面会变成重复请求、重复回调、状态错乱。

6. 页面销毁后,异步回调还在改状态

启动之后经常伴随异步动作,比如查详情、拉配置、校验登录。页面销毁后回调还更新状态,就会出现偶现闪跳或者日志报错。

建议给异步任务加 taskId,或者在页面消失时取消。别让旧任务回来覆盖新页面。

稳定性优化:给启动链路加一个任务号

如果启动入口多,建议给每次 LaunchPayload 分配一个自增序号。后到的启动意图优先级更高,旧任务回来不能覆盖新状态。

这个设计不复杂,但很管用。

// common/launch/LaunchSession.ets
import { LaunchPayload } from './LaunchPayload'

export class LaunchSession {
  private currentSeq: number = 0
  private latest?: LaunchPayload

  next(payload: LaunchPayload): number {
    this.currentSeq += 1
    this.latest = payload
    return this.currentSeq
  }

  isLatest(seq: number): boolean {
    return seq === this.currentSeq
  }

  getLatest(): LaunchPayload | undefined {
    return this.latest
  }
}

页面或 RouterService 使用时:

const seq = launchSession.next(payload)

this.loadBizData(payload).then(() => {
  if (!launchSession.isLatest(seq)) {
    // 旧入口触发的异步结果,不允许覆盖新入口状态。
    return
  }

  // 更新页面或路由状态
})

用户从通知点进来,半秒后又从服务卡片点进来,两次入口都可能触发异步加载。没有序号保护,旧请求后回来就能把新页面状态盖掉。

很多“偶尔跳错详情”的问题,本质就是旧任务覆盖了新任务。这个问题不加日志很难看出来,加了任务号以后,一眼就能看出是谁回来晚了。

哪些场景更适合这么做

这套启动治理不是所有 demo 都需要。一个只有首页和设置页的小工具,没必要上来就搞一堆入口层封装。

但下面几类应用,我建议早点做:

  • 内容类应用:从通知、搜索、外部链接进入文章或视频详情。
  • 办公类应用:服务卡片进入审批、待办、日程详情。
  • 电商和本地生活:活动链接、订单通知、桌面快捷入口都要落到不同业务页。
  • 工具类应用:从分享、文件打开、Deep Link 进入不同编辑模式。
  • 多 UIAbility 应用:主界面、独立编辑器、沉浸式展示页需要不同启动实例策略。

只要入口超过两个,就建议尽早把 Want 解析收敛掉。别等首页堆到几百行再重构,到那时候你已经分不清哪段逻辑是给哪个入口补的了。

结尾:入口治理写早一点,后面少还很多债

HarmonyOS 的 AbilityStage、Want、UIAbility,不只是应用模板里那几个默认文件。它们更像应用的入口骨架。

骨架稳了,页面和业务路由才不会到处补洞。

我的习惯是:AbilityStage 只做轻量进程级准备和分流;Want 进入应用后马上转成 LaunchPayload;UIAbility 统一处理冷启动和二次拉起;页面只消费归一后的业务参数。

看着多了几个类,但后面加入口、查问题、做灰度、做埋点,都会轻松很多。

别把启动参数散落在每个页面里。页面一多,谁都不愿意碰;入口一多,问题就开始像玄学。启动链路这种东西,越早工程化,越不容易在上线后给自己挖坑。

让 AI 从称手到称心 - 肘子的 Swift 周报 #134

作者 Fatbobman
2026年5月4日 22:00

从开始深度使用 AI 工具至今已有三年。三年间,我亲历了 AI 能力的飞跃,也越来越清晰地触摸到它的边界。截至目前,AI 早已是非常出色的效率工具,但如何让它写出真正“称心”——符合我个人风格、想法与设计哲学——的代码,仍是一个不小的挑战。

Neo 构建鸿蒙应用【三】:实战社交应用与工程感悟

作者 LeesonWong
2026年5月2日 23:53

Neo 构建鸿蒙应用【三】:实战社交应用与工程感悟

Neo 框架连载(终篇)· AI 辅助撰写

前两篇讲完了架构和机制。这一篇换个角度——不谈概念,只看代码。用一个模拟 Soul 业务场景的社交应用完整实现,验证框架在真实项目中的表现,最后分享一些工程实践中的感悟。

示例项目:EchoApp

EchoApp 是 Neo 仓库中的示例项目(examples/soul-app),模拟一款社交应用的核心功能:聊天、广场动态、匹配、用户资料等。不是 demo 级别的 HelloWorld,而是有意按照中型项目的体量来组织代码:

维度 数量
Service 总数 27
infra 层 8
business 层 12
feature 层 5
lazy 层 2
页面 10
组件 40+

选择这个体量是有原因的——太少了看不出分层的价值,太多了读起来成本太高。27 个 Service 恰好在"能看清楚全貌"和"有足够的复杂度"之间。

从零开始:AppModule 的设计思路

AppModule 是整个应用的起点,所有 27 个 Service 在这里统一声明:

export const appModule = new NeoModule('EchoApp', [
  // ===== GLOBAL_PHASE (serial, p10) — 8 infra services =====
  { tag: 'AppInitService', phase: GLOBAL_PHASE, factory: () => new AppInitService() },
  { tag: 'SecurityService', phase: GLOBAL_PHASE,
    factory: () => new SecurityService(), dependencies: ['AppInitService'] },
  { tag: 'DatabaseService', phase: GLOBAL_PHASE,
    factory: () => new DatabaseService(), dependencies: ['AppInitService'] },
  { tag: 'NetworkService', phase: GLOBAL_PHASE,
    factory: () => new NetworkService(), dependencies: ['AppInitService', 'SecurityService'] },
  { tag: 'CacheService', phase: GLOBAL_PHASE, factory: () => new CacheService() },
  { tag: 'StorageService', phase: GLOBAL_PHASE,
    factory: () => new StorageService(), dependencies: ['AppInitService'] },
  // ...

  // ===== BUSINESS_PHASE (serial, p20) — 12 business services =====
  { tag: 'AuthService', phase: BUSINESS_PHASE,
    factory: () => new AuthService(), dependencies: ['NetworkService', 'SecurityService', 'StorageService'] },
  { tag: 'UserService', phase: BUSINESS_PHASE,
    factory: () => new UserService(), dependencies: ['AuthService', 'DatabaseService'] },
  { tag: 'IMService', phase: BUSINESS_PHASE,
    factory: () => new IMService(), dependencies: ['AuthService', 'NetworkService', 'DatabaseService'] },
  { tag: 'MomentService', phase: BUSINESS_PHASE,
    factory: () => new MomentService(), dependencies: ['AuthService', 'NetworkService', 'CacheService'] },
  { tag: 'MatchService', phase: BUSINESS_PHASE,
    factory: () => new MatchService(), dependencies: ['AuthService', 'NetworkService', 'CacheService'] },
  // ...

  // ===== FEATURE_PHASE (parallel, p30) — 5 feature services =====
  { tag: 'SearchService', phase: FEATURE_PHASE,
    factory: () => new SearchService(), dependencies: ['NetworkService', 'CacheService'] },
  { tag: 'AnalyticsService', phase: FEATURE_PHASE,
    factory: () => new AnalyticsService() },
  // ...

  // ===== LAZY_PHASE (parallel, p40) — 2 lazy services =====
  { tag: 'GameService', phase: LAZY_PHASE,
    factory: () => new GameService(), dependencies: ['MatchService', 'IMService'] },
  { tag: 'StoryService', phase: LAZY_PHASE,
    factory: () => new StoryService(), dependencies: ['AuthService', 'NetworkService', 'MediaService'] },
])

设计这个文件时的思考

1. 分层的判断标准

哪些 Service 放 infra、哪些放 business,不是拍脑袋决定的。判断标准:

  • infra:无业务状态,换一个应用也能用。NetworkService、CacheService、DatabaseService——它们不知道"用户"是什么概念
  • business:有业务状态,和具体应用绑定。AuthService 知道 token,IMService 知道消息,MomentService 知道动态
  • feature:锦上添花,应用没有它们也能跑。SearchService、AnalyticsService
  • lazy:用户可能永远不会用到的功能。GameService、StoryService

2. 依赖的方向

依赖关系一定是单向的:infra ← business ← feature ← lazy。不会出现 IMService 依赖 SearchService 的情况。这不是框架强制的,而是分层的自然结果——你不会在业务层引用一个功能层的服务,因为业务层在它之前就加载了。

3. Phase 的调整是启动优化的主要手段

假设启动速度不达标,优化思路不是去改 Service 内部代码,而是调整 Phase 归属:

  • MatchService 从 BUSINESS 降到 FEATURE?匹配结果不在首屏展示,可以先显示骨架屏
  • EmojiService 从 BUSINESS 降到 FEATURE?表情面板不是默认展开的
  • 每降一个,BUSINESS 阶段就少一个串行等待的 Service,用户更快看到首页

这种优化不需要改任何业务代码,只改 AppModule 中的 phase 字段。

一个完整的数据流:发送消息

以聊天功能为例,走一遍从用户操作到 UI 刷新的全链路。

页面层(features)

// ChatPage.ets
@Entry
@Component
struct ChatPage {
  @State messages: ChatMessage[] = []
  @State inputText: string = ''
  private imSvc?: IMService
  private unbind: (() => void) | undefined
  private conversationId: string = ''

  aboutToAppear() {
    const params = router.getParams() as ChatPageParams
    this.conversationId = params.conversationId

    this.imSvc = serviceManager.get<IMService>('IMService')!
    this.unbind = StateBinder.bind<ChatMessage[]>(
      this.imSvc.getMessagesObservable(),
      this as Object,
      'messages'
    )
    this.loadMessages()
  }

  aboutToDisappear() { this.unbind?.() }

  async loadMessages() {
    if (this.imSvc && this.conversationId) {
      this.messages = await this.imSvc.getMessages(this.conversationId)
      await this.imSvc.markAsRead(this.conversationId)
    }
  }

  // 用户点击发送
  async onSend() {
    if (this.imSvc && this.inputText.trim()) {
      await this.imSvc.sendMessage(this.conversationId, this.inputText.trim())
      this.inputText = ''
    }
  }

  build() {
    Column() {
      List() {
        ForEach(this.messages, (msg: ChatMessage) => {
          ListItem() {
            // 渲染消息气泡
          }
        })
      }
      // 输入框 + 发送按钮
    }
  }
}

页面做的事情很薄:绑定 Observable、调用 Service 方法、渲染 UI。没有网络请求、没有状态管理、没有回调地狱。

业务层(domains)

// IMService.ets
export class IMService extends Service {
  private conversationsObs = new Observable<Conversation[]>([])
  private messagesObs = new Observable<ChatMessage[]>([])

  getMessagesObservable() { return this.messagesObs }

  async sendMessage(conversationId: string, content: string): Promise<ChatMessage> {
    // 1. 业务逻辑:创建消息
    const msg = this.createMessage(conversationId, content)

    // 2. 网络:发送到服务器
    await this.networkSvc.send('/im/send', msg)

    // 3. 更新 Observable → 自动通知所有绑定的页面
    const msgs = this.messagesObs.getValue()
    this.messagesObs.setValue([...msgs, msg])

    // 4. 发送事件 → 其他 Service 可以响应
    eventBus.emit('im:messageSent', { conversationId, message: msg } as Object)

    return msg
  }
}

业务层做的事情:处理业务逻辑、协调基础设施、更新 Observable、发送事件。它不知道有哪些页面在用自己,也不知道 UI 长什么样。

基础设施层(infra)

// NetworkService.ets
export class NetworkService extends Service {
  async send(path: string, data: Object): Promise<Object> {
    // 纯技术实现:HTTP 请求、重试、超时
    // 不知道"消息"是什么,只知道 path 和 data
  }
}

基础设施层做的事情:纯技术实现。不知道业务概念,不知道 UI,甚至不知道自己在被谁调用。

完整链路

用户点击发送(ChatPage.onSend)
  → imSvc.sendMessage(convId, '你好')        // 页面调 Service
    → 创建消息对象                            // 业务逻辑
    → networkSvc.send('/im/send', msg)        // 调基础设施
    → messagesObs.setValue([...msgs, msg])    // 更新 Observable
      → StateBinder 自动写入 @State messages  // 数据驱动 UI
      → ArkUI 重新渲染消息列表                 // 用户看到新消息
    → eventBus.emit('im:messageSent')         // 通知其他 Service

页面不需要手动 this.messages = newMessages,不需要 this.$setState(),不需要 notifyDataSetChanged()。Service 调了 setValue,UI 自动刷新。

新增一个功能要改几个文件

假设产品要求新增一个"举报"功能。需要改哪些文件?

1. 定义 Serviceservices/feature/ReportService.ets(新建)

export class ReportService extends Service {
  async report(targetId: string, reason: string): Promise<boolean> {
    const network = this.depServices.find(s => s.tag === 'NetworkService') as NetworkService
    await network.send('/report', { targetId, reason } as Object)
    return true
  }
}

2. 注册到 AppModulemodules/AppModule.ets(加一行)

{ tag: 'ReportService', phase: FEATURE_PHASE,
  factory: () => new ReportService(), dependencies: ['NetworkService'] },

3. 页面使用 — 在需要举报的页面中

const reportSvc = serviceManager.get<ReportService>('ReportService')!
await reportSvc.report(targetId, '不适当内容')

三个文件,零耦合。不需要修改 NetworkService、不需要修改任何基类、不需要在某个全局注册表里手动添加。定义 → 注册 → 使用,流程是固定的。

工程感悟

框架是团队契约,不是技术炫耀

我说这么多有的没的,核心就一件事:如果存在一种共同约束,不是特别完美,但也不特别烂,就能产生一些价值。

精准高效沟通是特例,低效沟通是常态;层次分明是特例,层次渗透是常态;需求稳定是特例,经常变更是常态。Neo 提供的不是理想架构,而是一个团队可以共同遵守的底线。所有人定义 Service 的时候知道 init 不能做 IO,声明依赖的时候知道要看 AppModule,写页面的时候知道通过 Observable 拿数据。

这种共同约束的收益不是立竿见影的,而是在项目三个月、六个月、一年后——当新功能还在源源不断地加进来,而代码还能看懂、还能改、还能测试的时候——才会体现。

ArkTS 的限制反而帮了忙

ArkTS 比 TypeScript 严格很多:不能用 any、不能用 Record<> 的动态访问、不能省略泛型、不能 spread 对象。这些限制在初期让人头疼,但回头看,它们迫使你写出更明确的代码:

  • 没有 any → 每个变量都有类型 → Service 的接口必须定义清楚
  • 不能动态访问属性 → 必须用接口声明 router.getParams() 的返回值 → 页面参数有文档
  • 必须显式泛型 → StateBinder.bind<T>() 一眼就知道绑的是什么类型

这些限制和 Neo 的设计理念是契合的——约束带来清晰

结构化不是炫技

尤其在近两年 AI 编程越来越成熟的背景下,"炫技"显得越来越廉价。结构化的目的是让代码可维护、可测试、可交接,而不是展示设计模式的熟练度。

AI 时代的工程节奏

Neo 本身就是 AI 辅助开发的产物。我的判断是:AI 生成产品的速度太快了,把 IDEA 尽可能快地做出来,远比人工打磨细节的 ROI 高。

本系列文章也是这个思路——核心观点和设计决策是人定的,文字落地是 AI 做的。与其花一周打磨一篇"完美"的技术文章,不如一天发三篇把思路讲清楚,把省下来的时间投入到下一个功能、下一个项目中。

写在最后

软件是有固有生命周期的,公司和团队也有生命周期。在经济上行期软件行业的迭代都非常快,很多软件在没达到最大容积之前,公司和团队的生命周期已走到最终阶段。

这不是悲观——恰恰是因为时间有限,才更需要一个好的结构约束。让代码在你还在的时候能跑,在你走了之后别人也能接。Neo 不追求成为最好的框架,只追求成为一个不太烂的共同约束

如果这篇文章对你有一点启发,去 GitHub 看看源码,跑一下 EchoApp 示例。觉得有用就点个 Star,有问题就提 Issue。

共勉。


系列文章

Neo 构建鸿蒙应用【二】:技术路线全解

作者 LeesonWong
2026年5月2日 23:51

Neo 构建鸿蒙应用【二】:技术路线全解

Neo 框架连载 · AI 辅助撰写 · GitHub

上一篇建立了四层架构的宏观视图。这一篇把 Neo 的全部技术机制讲完:Service、NeoModule、ServiceManager、Phase、Observable、StateBinder、Scope。

核心思路借鉴了 JavaBean 和 IoC——Java Boy 编程梦开始的地方。

Service:最小功能单元

所有领域建模被命名为 XXXService,继承 Service 基类。一个 Service 的完整生命周期:

constructor → register → init → loginCallback → load → (WORKING)
                                                  ↑
                                    reload ← unload ← logoutCallback

基类定义

export abstract class Service implements AppPropagation {
  tag: string = this.constructor.name    // 默认用类名作标识
  context: Context | undefined
  depServices: AppPropagation[] = []     // 依赖的服务列表

  constructor(services: Service[]) {
    this.depServices = services
  }

  register = (cxt: Context) => {
    this.context = cxt
    this.init()
    // lifecycle → INITIALIZED
  }

  loginCallback = async () => {
    this.load().then(res => {
      if (res) {
        // lifecycle → WORKING
        serviceManager.refreshNode(this, this.lifecycle)
      }
    })
  }

  logoutCallback = async () => { /* unload → INITIALIZED */ }

  init() {}                                        // 初始化成员属性,不能做网络 IO
  async load(): Promise<boolean> { return true }   // 加载数据
  async unload(): Promise<boolean> { return true } // 卸载清理
  reload = () => { this.unload().then(() => this.load()) }
}

设计要点:

  • register 是箭头函数属性,不是方法。ArkTS 中子类不能 override 父类的箭头函数属性,保证基类的生命周期管理不被绕过
  • constructor(services: Service[]) 构造器注入,Spring 的思路
  • init() 不能做网络 IO——仅用于初始化成员属性
  • load() 返回 Promise<boolean>——true 表示成功,状态转为 WORKING

实际例子

// AuthService.ets
export class AuthService extends Service {
  private currentUser: UserInfo | null = null
  private token: string = ''

  constructor() { super([]) }

  init() { this.currentUser = null; this.token = '' }

  async load(): Promise<boolean> {
    const saved = await this.loadFromStorage()
    if (saved) { this.token = saved.token; this.currentUser = saved.user }
    return true
  }

  async unload(): Promise<boolean> {
    this.currentUser = null; this.token = ''
    return true
  }
}

NeoModule:Koin 式模块声明

Service 定义好了,如何组织起来?借鉴 Koin 的 module DSL 风格:

import { NeoModule, GLOBAL_PHASE, BUSINESS_PHASE } from 'neo'

const networkModule = new NeoModule('Network', [
  { tag: 'ApiService', phase: GLOBAL_PHASE, factory: () => new ApiService([]) },
  { tag: 'AuthService', phase: GLOBAL_PHASE, factory: () => new AuthService([]) },
])

const userModule = new NeoModule('User', [
  { tag: 'UserService', phase: BUSINESS_PHASE,
    factory: () => new UserService([]),
    dependencies: ['AuthService'] },
])

每个声明包含:

字段 说明
tag 服务唯一标识
phase 所属加载阶段
factory 延迟创建工厂
dependencies 依赖的其他服务 tag

模块组合:

const appModule = networkModule.merge(userModule)
// 同名声明以当前模块优先

校验(load() 时自动调用):

const errors = appModule.validate()
// 检测缺失依赖 + 循环依赖(DFS)

ServiceManager:IoC 容器

中枢管理者,处理注册、依赖解析和生命周期。

核心流程

// EntryAbility.onCreate
serviceManager.register(this.context)          // 注入上下文
serviceManager.loadModule(appModule)            // 加载模块(校验、分组、注册)
await serviceManager.loginCallback()            // 触发多阶段启动

依赖解析

内部维护两张映射:serviceMap(tag → 实例)和 dependentMap(被谁依赖)。loginCallback 触发时递归解析依赖链——加载 Service A 之前,确保其所有依赖已 WORKING:

// ServiceManager 内部(简化)
private ensureNodeWorking(node) {
  await Promise.all(node.depServices.map(dep => this.ensureNodeWorking(dep)))
  this.invokeLogin(node)
  await this.waitForLifecycle(node.tag, ServiceLifeCycle.WORKING)
}

获取服务:

const userService = serviceManager.get<UserService>('UserService')!
const isReady = serviceManager.ready(userService)

Phase:渐进式启动

27 个 Service 不需要同时加载。

四个预定义阶段

阶段 优先级 策略 用途
GLOBAL_PHASE 10 串行等待 配置、数据库、网络、安全
BUSINESS_PHASE 20 串行等待 认证、用户、消息、通话
FEATURE_PHASE 30 并行触发 搜索、统计、主题、国际化
LAZY_PHASE 40 并行触发 小游戏、故事

策略含义:

  • 串行等待waitForComplete: true):该阶段全部完成后才进入下一阶段
  • 并行触发waitForComplete: false):触发后立即进入下一阶段,不等待完成
时间轴 ─────────────────────────────────────────────→

GLOBAL (串行)  ████████████████
BUSINESS (串行)                 ████████████████
FEATURE (并行)                                   ████ ← 不阻塞
LAZY (并行)                                           ██ ← 不阻塞

                     ↑ UI 可交互

启动耗时可控的核心:GLOBAL + BUSINESS 同步确保核心就绪,FEATURE + LAZY 异步不阻塞。优化手段就是把非必要 Service 从 BUSINESS 降到 FEATURE。

实战中的启动优化

我曾在实际项目中用这套机制做过启动优化(详见原文),当时 Neo 还不存在,但核心代码已经具备了 Service + Phase 的雏形。

优化成果固然可喜,但更重要的是整个结构变得可控了。因为每个 Service 的依赖关系、加载阶段、生命周期都是声明式的,我可以保证:

  • 把某个 Service 从 BUSINESS 降到 FEATURE,不会导致依赖它的模块出问题——依赖解析是自动的
  • 新增一个 Service 不需要改任何已有代码——在 AppModule 加一行声明即可
  • 变更的影响范围是可预期的——约束由框架兜底

这不是黑盒优化,而是白盒优化。以后遇到启动性能问题,打开 AppModule 调整 Phase 归属即可——不需要重新分析依赖链,不需要画调用图,套路是固定的。

自定义阶段:

const CACHE_PHASE = createPhase({
  name: 'CACHE', priority: 25, waitForComplete: false, description: '缓存预热'
})

Observable:Service 端的数据源

Service 加载了数据,如何让页面感知变化?

朴素做法是页面直接调 Service 方法拿返回值赋给 @State——但只在初始化时有效,其他页面的数据变更不会自动刷新。

Neo 的方案是 Observable——泛型观察者容器:

export class Observable<T> {
  private value_: T
  private listeners: Set<(value: T) => void> = new Set()

  constructor(initialValue: T) { this.value_ = initialValue }
  getValue(): T { return this.value_ }

  setValue(newValue: T): void {
    if (this.value_ === newValue) return
    this.value_ = newValue
    this.notify()
  }

  onChange(listener: (value: T) => void): () => void {
    this.listeners.add(listener)
    return () => { this.listeners.delete(listener) }
  }
}

没有 Subject、没有 Operator、没有调度器。ArkTS 不支持 RxJS 那套管道,也没有必要引入。

Service 中使用

export class IMService extends Service {
  private conversationsObs = new Observable<Conversation[]>([])
  private messagesObs = new Observable<ChatMessage[]>([])

  getConversationsObservable() { return this.conversationsObs }
  getMessagesObservable() { return this.messagesObs }

  async sendMessage(conversationId: string, content: string): Promise<ChatMessage> {
    const msg = await this.doSend(conversationId, content)
    const msgs = this.messagesObs.getValue()
    this.messagesObs.setValue([...msgs, msg])  // 自动通知 UI
    return msg
  }

  async load(): Promise<boolean> {
    this.conversationsObs.setValue(await this.fetchConversations())
    return true
  }
}

模式:内部持有 Observable → getter 暴露 → setValue 触发变更。外部只能订阅,不能直接修改。

StateBinder:Service 到 @State 的桥

Observable 解决了通知问题,StateBinder 解决了写入 @State 的问题:

export class StateBinder {
  static bind<T>(
    observable: Observable<T>,
    component: Object,      // 页面组件实例
    stateKey: string        // @State 属性名
  ): () => void {
    const record = component as Record<string, Object>
    record[stateKey] = observable.getValue() as Object    // 初始化
    const unlisten = observable.onChange((newValue: T) => {
      record[stateKey] = newValue as Object               // 变更时自动写入
    })
    return unlisten
  }

  static bindAll(factories: Array<() => () => void>): () => void { /* 批量绑定 */ }
}

页面中使用

@Entry
@Component
struct ChatListPage {
  @State conversations: Conversation[] = []
  private unbind: (() => void) | undefined

  aboutToAppear() {
    const imSvc = serviceManager.get<IMService>('IMService')!
    this.unbind = StateBinder.bind<Conversation[]>(
      imSvc.getConversationsObservable(),
      this as Object,       // ArkTS 要求显式转换
      'conversations'
    )
  }

  aboutToDisappear() { this.unbind?.() }

  build() {
    List() {
      ForEach(this.conversations, (conv: Conversation) => {
        ListItem() { /* 渲染会话项 */ }
      })
    }
  }
}

注意两点 ArkTS 限制:

  • this as Object 不能省略——组件的 this 类型不允许直接传给 Object 参数
  • 显式泛型 <T> 不能省略——ArkTS 不支持自动推断泛型给 Record<string, Object> 赋值

批量绑定:

aboutToAppear() {
  this.unbind = StateBinder.bindAll([
    () => StateBinder.bind<Conversation[]>(imSvc.getConversationsObservable(), this as Object, 'conversations'),
    () => StateBinder.bind<ChatMessage[]>(imSvc.getMessagesObservable(), this as Object, 'messages'),
    () => StateBinder.bind<number>(matchSvc.getCountObservable(), this as Object, 'matchCount'),
  ])
}

aboutToDisappear() { this.unbind?.() }  // 一行解绑所有

Scope:页面级作用域

有些 Service 是页面专属的,页面离开时应该卸载:

import { scopeManager } from 'neo'

aboutToAppear() {
  this.scope = scopeManager.createScope('ChatPage')
  this.scope.bindTo(this)  // aboutToDisappear 时自动 close

  const chatRoomSvc = new ChatRoomService([])
  this.scope.register(chatRoomSvc)
}
// 页面销毁 → scope.close() → unload 所有注册的服务

Scope 查找机制:优先从作用域内找,找不到回退到全局 ServiceManager。scope.get<T>('ChatRoomService') 获取页面级服务,scope.getGlobal<T>('AuthService') 获取全局服务。

小结

Neo 的完整技术路线:

组件 职责
Service 功能单元,声明式生命周期
NeoModule Koin 式模块声明,组合与校验
ServiceManager IoC 容器,依赖解析
Phase 多阶段加载,串行/并行策略
Observable Service 端可观察数据源
StateBinder Service 数据 → @State 响应式桥
Scope 页面级作用域,自动卸载

下一篇是最后一篇,用 SoulApp 示例串起全链路,并分享这个框架在工程实践中的一些感悟。


系列文章

Swift 并发正被更广泛地接纳 - 肘子的 Swift 周报 #133

作者 Fatbobman
2026年4月27日 22:00

从 Swift 5.5 引入符合现代编程思想的新并发模型算起,一转眼快 5 年了。从 5.5 到目前的 6.3,Swift 社区一直在采用小步迭代的方式,积极推进并发 API 的演进。但在应对过多的新关键字、复杂的隔离概念以及一些容易引发困扰的“反模式”时,这个过程对开发者来说并不算顺利。

smart 精灵 6 号开启预售:从城市小车到豪华掀背,smart 变大了,也变得更复杂了丨北京车展

作者 刘学文
2026年4月27日 13:28

在很多人的记忆里,smart 曾经是城市小车的代名词。

它小巧、灵活、带着一点玩具感,也带着一种很明确的都市生活方式表达。那时候的 smart,并不试图覆盖所有用车场景,它更像是一台适合在欧洲老城、上海弄堂、北京胡同之间穿行的小车。它的价值不在于「大而全」,而在于用一种足够鲜明的姿态,回答城市交通中「小而精」的问题。

但今天的 smart,已经不再只是一家小车品牌,奔驰负责设计底蕴,吉利提供新能源制造和工程体系,smart 也从过去的燃油小车,逐渐转向新奢智能电动车品牌。这种变化,在 2026 北京车展上表现得尤其明显。

一边是 smart 精灵 2 号概念车亮相,它试图回应 smart 经典两座小车的精神,让小车继续向精品化进化;另一边,smart 品牌首款豪华掀背轿车精灵 6 号正式开启预售,预售价 18.99 万元起。

这两款车放在一起,刚好构成 smart 当前的两条产品线索:小车继续负责灵动和个性,大车则要进入更主流、更复杂的家庭和长途出行场景。精灵 6 号,就是后者。

从城市小车到豪华掀背,smart 开始处理更复杂的用车需求

过去 smart 的核心印象是「小」,但精灵 6 号要面对的用户,显然不再只是单人通勤或双人城市出行。它需要满足一家人的空间需求,需要有长途出行能力,也需要在 20 万元左右的价格区间里,与一批更成熟、更卷配置的新能源轿车和混动车竞争。所以,精灵 6 号并没有把自己包装成传统意义上的运动掀背车,而是用了「新一代豪华掀背轿车」这个定位。

从设计上看,它依然试图保留 smart 的个性。车辆由梅赛德斯-奔驰全球设计团队操刀,灵感来自鲨鱼,整体姿态强调流线、低趴和力量感。相比传统三厢轿车,掀背结构天然带来更强的整体感,也让车尾拥有更大的开口和更强的功能性。

更有辨识度的是灯光系统。精灵 6 号搭载 PixelTalk 智感交互光阵,前后大灯共内嵌 10666 颗 LED 毫米级微点阵,支持官方灯效和用户自定义灯语。过去,车灯更多承担照明和辨识功能;现在,很多新能源车开始把车灯当成对外交流界面。比如迎宾、告别、充电提示,甚至一些简单情绪表达,都可以通过灯光完成。

这类设计是否必要,取决于用户是否愿意把车当作一种个性表达。但至少可以看出,精灵 6 号仍然没有完全放弃 smart 过去那种「有点好玩」的品牌性格。

进入车内,精灵 6 号的重点不只是屏幕,而是氛围。它的座舱被称为「全感超享生活舱」,其中有几个比较有 smart 特征的配置:星光涡轮出风口、近 800 处发光点、迈巴赫同款 256 色环抱式氛围灯,以及汽车行业首创的「飞碟」造型悬浮升降高频扬声器。

这些配置放在一起,形成的不是传统豪华车那种木纹、皮革和镀铬堆叠出来的庄重感,而是一种更年轻、更偏情绪价值的座舱表达。比如夜晚下班后上车,解锁、开门、扬声器升起、氛围灯亮起,这些动作未必会改变车辆的基础功能,但会改变人进入座舱那一刻的感受。对于今天的新能源用户来说,车不再只是从 A 点到 B 点的工具,也越来越像一个移动的私人空间。灯光、声音、屏幕、香氛、座椅,都会参与塑造这个空间的情绪。

音响方面,精灵 6 号搭载森海塞尔典范音响系统,全车 20 个扬声器,并且可以与氛围灯联动。对于一台主打年轻家庭和品质用户的车来说,音乐场景非常重要。它既可能发生在一个人通勤的路上,也可能发生在周末郊游返程时,后排家人休息,前排轻声播放一张熟悉的专辑。

相比简单强调屏幕数量,这种围绕听觉和氛围展开的座舱体验,更符合 smart 想要表达的「新奢」方向。

传统掀背轿车经常会遇到一个矛盾:外观姿态好看,但后排头部空间容易被压缩;尾门开口大,但车内垂直空间和后备箱规整度未必理想。尤其当这台车要进入家庭场景时,空间就不能只停留在参数上。

精灵 6 号延续了 smart 过去「四轮四角」的设计思路,官方给出的空间利用率达到 86%。后排头部空间为 963mm,后排膝部空间为 135mm,后排靠背支持最大 122° 电动调节。这些数字对应到实际场景里,大概是这样的:后排乘客不必用过于直立的坐姿换取头部空间;长途行驶时,靠背角度可以更放松;一家人出门时,后排不再只是临时坐人的位置,而是可以长时间乘坐的区域。

后备箱则是掀背车的优势所在。

精灵 6 号采用 SUV 式大开口尾门,开启高度接近 1.9m,开口宽度超过 1m,常规后备箱容积为 525L。满员状态下,它可以放下多个行李箱;放倒后排后,能够形成纵深接近 2m、接近纯平的储物空间。

这意味着它可以覆盖不少真实生活场景:周末带孩子骑车,把儿童自行车放进后备箱;短途露营时塞进折叠椅、天幕和收纳箱;搬家或采购时,放下几件大件物品。对于一台轿车来说,这种装载能力会显著拓宽它的使用半径。这也是精灵 6 号选择掀背形态的重要原因。它不是单纯为了好看,而是想在轿车姿态和 SUV 实用性之间找一个折中点。

动力方面,精灵 6 号采用雷神电混 EVO 发动机,热效率 47.26%。官方资料显示,车辆 CLTC 综合续航最高可达 1810km,馈电油耗为 3.9L/100km,并且只需要使用 92 号汽油。补能方面,电量从 30% 充至 80% 最快 15 分钟。

如果只是城市通勤,精灵 6 号可以更接近一台电动车使用。按照官方给出的场景,每天 20km 左右通勤,满电状态下可以覆盖较长时间的日常出行。用户可以在家里或单位完成低成本补能,减少加油频率。

到了长途场景,混动系统的优势会更明显。比如从北京去海边、从上海去周边城市,或者节假日跨省出行,用户不需要把路线完全绑定在充电站上。服务区排队、冬季续航衰减、目的地充电条件不确定,这些纯电车用户熟悉的问题,在长续航混动车上会被明显弱化。

动力方面,精灵 6 号搭载 P3 高性能驱动电机,峰值功率 200kW,峰值扭矩 380N·m,并具备 7 种智能混动模式。它并不是只追求低油耗,而是希望在城市、快速路、高速和亏电等不同工况下,自动选择更合适的工作模式。对于 18.99 万元起售的车型来说,这种「可电、可油、可长途」的能力,会是它进入主流家庭市场的重要基础。

底盘和安全,则是 smart 想补齐的另一面。精灵 6 号强调由梅赛德斯-奔驰全球专家团队联合调校,并配备采埃孚在国内首发的闭环转向系统、FSD 液压可变阻尼减振器,以及 4 档智能可调电动尾翼。

这些配置共同指向一个目标:让一台近 5 米长的大车,开起来不要显得笨重。

对于用户来说,底盘感受通常不会在短视频里被直观看到,但会在日常使用里持续出现。比如城市高架上的连续变道,车身是否有多余晃动;高速巡航时,方向是否足够稳;过减速带和破损路面时,悬架能否过滤震动;雨天急刹时,车辆是否能保持姿态稳定。

官方资料显示,精灵 6 号全系标配 255mm 宽轮胎,百公里干地制动距离为 33.87m,80km/h-0 湿地制动距离为 26.6m。车辆还以 131km/h 的速度通过 ISO 3888-1 标准高速麋鹿测试。这些数据需要在实际试驾中验证体感,但至少说明,smart 并不只想把精灵 6 号做成一台空间更大的家用车,也希望它保留一部分德系驾驶质感。

安全方面,精灵 6 号整车高强钢与铝合金占比达到 85%,热成型硼钢占比 16%,并采用笼式车身和神盾电池安全系统。针对掀背车型尾部结构,车辆加入「蜂巢抗侵环」结构,对 C 柱区域进行加固和结构优化。这类结构设计,尤其会影响后排和尾部碰撞场景下的保护能力。掀背车因为尾门开口更大,尾部结构天然更复杂,如何在造型和装载能力之外保证车身刚性,是它必须回答的问题。

智能化层面,精灵 6 号的一个关键信息是:全系标配激光雷达。官方资料显示,它搭载依托 WAM 世界行为模型的千里浩瀚智能驾驶辅助方案,高速场景下,全系标配高速 NSP,可实现自动变道超车、智能出入匝道;城市道路中,全系标配端到端城市无图 NSP,不依赖高精地图,也能处理部分复杂道路场景。

此外,精灵 6 号还提供智能泊车能力,覆盖垂直、平行、断头路、狭窄机械车位等场景。离车泊入过程中,用户可以中途介入,比如取放物品或乘员上下车,系统可识别并恢复泊车流程。这类功能对新手司机和家庭用户都很实际。很多时候,用户对智能驾驶的第一需求并不是「多激进」,而是它能否在高速长途中减轻疲劳,在陌生停车场里降低停车压力,在复杂城区里减少误判和紧张感。

18.99 万元起,精灵 6 号要面对的竞争比想象中激烈

从产品形态来看,精灵 6 号并不是一台特别容易被归类的车。它不是传统三厢轿车,也不是 SUV;它有接近 5 米的车长、掀背尾门、长续航混动系统、德系设计和底盘调校,还把激光雷达、智能驾驶和大空间作为核心卖点。它试图在个性、实用、长途、智能和豪华氛围之间找到平衡。

这也决定了它面对的竞争不会轻松。18.99 万元起的价格区间,正是中国新能源市场最拥挤的位置。这里既有主流自主品牌的插混轿车和 SUV,也有一批配置极高的纯电车型,还有越来越多强调智能驾驶、空间和家庭体验的新势力产品。消费者的选择很多,价格也被压得很紧。

在这样的市场里,smart 精灵 6 号不能只靠「奔驰设计」和品牌个性说服用户。它需要证明自己的混动系统足够高效,底盘调校足够成熟,智能驾驶足够稳定,座舱体验足够有记忆点,空间也确实能覆盖家庭使用。

它的机会在于,20 万元左右的市场并不缺大车,但缺少足够有个性的产品。很多车型把空间、续航、配置和价格做得很满,却很难让人记住。smart 精灵 6 号如果能把掀背造型、座舱氛围、长续航混动和德系驾控真正整合起来,就可能在高度同质化的市场中找到一条差异化路径。从这个意义上说,精灵 6 号是 smart 变大的结果,也是 smart 变复杂的开始。

它不再只是城市里灵巧的小车,而是一台要承担家庭、长途、智能和品质感的主流产品。对于 smart 来说,这一步能否走稳,将决定它能不能从「有个性的品牌」进一步变成「有规模的品牌」。

稳中向好。

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

鸿蒙呼吸动画踩了三个坑:GPU降级时机、设计Token校验、i18n漏key——具体怎么处理的

作者 SameX
2026年4月24日 08:28

在鸿蒙上做呼吸动画,我以为最难的是 ArkTS 语法,结果最麻烦的是——我根本不知道用户的设备跑到哪一档了。

呼吸动画是「呼吸视界」这个 App 的核心体验:吸气时圆圈缓慢扩张,屏气时保持,呼气时收缩。这个动画一旦卡顿,「跟着 App 呼吸」的节奏就断了,用户能感觉到「哪里不对」,但不会告诉你是帧率问题。

先说一下这个 App 是干什么的,方便后面的技术背景理解。

产品背景:一个给自己做的呼吸训练工具

「呼吸视界」(iOS App Store ID: 6758613852)做的是结构化呼吸训练引导——4-7-8 呼吸法、盒式呼吸、Wim Hof 法这些。网上这些方法的文字说明很多,但照着文字练,你得自己数秒、记顺序,练着练着就分心了。

我做这个的起因很功利:开会前容易紧张,想找个东西帮我两分钟之内把状态重置一下。找了一圈没找到合适的,就自己写了。

App 有三块核心功能:带动画节奏的引导式练习、本地持久化的训练记录、以及一个课程进度系统(不只是单次练习,而是完整的训练计划)。iOS 版目前评分 5 分,样本量不大,但有个用户说「可以跟随练习呼吸,保持稳定的心情」——说实话这个反馈比我预期的更朴实,我自己用下来觉得更直接的感受是:开会前真的有用,两分钟够了。

鸿蒙版最近发布,把移植过程里踩的几个坑整理一下。

坑一:呼吸动画的 GPU 降级,我不知道该在哪个阈值切

呼吸动画用 GPU 渲染时效果最好,过渡顺滑,缩放曲线自然。但鸿蒙设备碎片化比 iOS 严重得多,中低端机上 GPU 渲染直接掉帧,整个动画变得一顿一顿的。

所以我做了一套自适应降级:检测到性能不足时切到 Canvas fallback 模式,同时把当前渲染质量分成 highbalancedlow 三档。

问题来了:切换阈值怎么定?

我的判断方式是盯 frameMs(单帧渲染耗时)和连续低帧计数:

// 连续低帧超过阈值时触发降级
if (frameMs > 22 && consecutiveLowFpsCount >= 3) {
  // 22ms ≈ 45fps,低于此值且连续3帧 → 切 balanced
  adaptRenderer('degrade quality -> balanced');
  consecutiveLowFpsCount = 0;
}
if (frameMs > 33 && consecutiveLowFpsCount >= 3) {
  // 33ms ≈ 30fps,连续3帧 → 切 canvas fallback
  adaptRenderer('switch renderer -> canvas fallback');
}

这个阈值不是凭感觉拍的,是我把 hilog 日志抓出来跑脚本分析的结果。日志里会输出每帧的 fpsframeMstickMs 以及当前渲染质量档位,降级事件会打 BF_PERF_ADAPT 标签,比如 degrade quality -> balanced 或者 switch renderer -> canvas fallback

对独立开发者来说这套日志分析挺重要——没有 QA、没有用户主动反馈卡顿,只能靠工具自己发现问题。我在没有真机的情况下,靠日志回放重现了好几个卡顿场景。

目前 Canvas 模式下动画过渡还是不如 GPU 顺滑,这个还在打磨,算是没解决干净的问题。

坑二:设计 Token 漏用——一个脚本比 code review 更可靠

App 的调性是「平静克制」,UI 上我比较在意所有间距、圆角、阴影、动画时长要统一。如果哪个地方直接写了魔法数字,整体质感就散了。

鸿蒙版我把所有设计 token 收进一个 Style.ets,导出四个命名空间:SPACERADIUSSHADOWMOTION。问题是开发过程中很容易手滑——改某个组件时直接写 borderRadius(8) 而不是 RADIUS.card,这种事我自己也干过。

所以我写了一个 check_design_foundation.py,逻辑很简单:用 path.read_text() 读取关键文件内容,用字符串匹配检查是否包含预期的 token 引用。比如检查 AppBackdrop.ets 里有没有 export struct AppBackdrop,检查 SheetBackground.ets 里有没有调用 AppBackdrop(,检查根页面 RootPage.ets 里有没有 AppBackdrop({

不是正则匹配魔法数字(那个误报太多),而是检查「关键结构是否存在」——更像一个架构约束验证器。

真实案例:有一次我重构了背景组件 AppBackdrop,改了对外接口,但忘了更新 SheetBackground 里的调用方式,就是被这个脚本拦下来的。如果没有这个检查,这个问题可能得等到真机运行时才会发现。

我还把几个类似的脚本整合进一个 check_foundation_alignment.py,统一管理:设计 token 校验、按压反馈检查、页面过渡检查、i18n 对等检查、路由检查——提交前一起跑,哪个挂了去修哪个。独立开发没有 code review,这套东西算是自己给自己兜底。

坑三:i18n 漏 key,双语维护是个持续性的低级错误

App 支持中英双语,维护四个语言文件:strings_app_en.etsstrings_app_zh-Hans.ets 以及对应的 base 版本。每次加新功能往里填 key,英文填了忘了填中文,或者反过来,这种事经常发生。

check_i18n_keys.py 做的事很直白:把四个文件里的 key 全部提取出来做集合差运算,输出「哪些 key 在英文有但中文没有」以及反向的情况。

这个脚本帮我发现过好几次漏掉的 key,有时候漏的是边缘功能的文案,有时候是一个按钮标题——后者如果漏了,用户看到的就是 key 字符串本身,很难看。

课程进度系统:本地存储是主动选择,不是偷懒

ProgramProgressRecord 记录用户在某个训练计划里完成了哪些 session、当前在第几阶段。数据全部本地存储,没有云同步。

说实话云同步我也不想做。OAuth 接入、服务器费用、隐私合规、多端数据冲突处理……这一套对独立开发者来说投入产出比太低。用户的训练记录放本地就够了,鸿蒙的 Preferences 和 RelationalStore 用起来比我预期顺手,持久化这块没遇到太大麻烦。

自定义呼吸节奏的交互,我还没想明白

用户可以自由设置吸气、屏气、呼气各阶段的时长。这个功能的交互我试了三版:滑动条、数字步进器、转盘——感觉都差点意思。滑动条精度不够,步进器操作次数太多,转盘在小屏上很难操作。

这个目前还搁着,UI 做得比较简陋。如果你做过类似的时长输入控件——尤其是整数秒精度、范围大概 1-30 秒的场景——很想听听你用了什么方案。

从 OpenSwiftUI 到 DanceUI:换个方式 Dive SwiftUI - 肘子的 Swift 周报 #132

作者 Fatbobman
2026年4月20日 22:00

从 2019 年问世算起,SwiftUI 已经快七年了。它早已脱去了最初几年的稚气,逐渐成为苹果生态开发者的基础能力之一。不过,SwiftUI 的闭源属性也意味着,它的很多运行机制始终不透明。开发者在使用时固然能感受到它的表达优势,但一旦遇到问题,往往很难进一步追踪原因。这种特性也让 SwiftUI 在 AI 辅助编程时代显得有些“吃亏”——相比那些长期暴露在社区讨论、源码和文档中的技术,大模型能参考的高质量材料终究有限。

被 Vibe 摧毁的版权壁垒,与开发者的新护城河 - 肘子的 Swift 周报 #131

作者 Fatbobman
2026年4月13日 22:00

Anthropic 不久前宣布,由于其最新模型 Mythos 在网络安全与代码漏洞挖掘方面的能力“过于强大”,已达到令人不安的程度,因此采取了极为罕见的克制措施:仅向 Project Glasswing 内的少数关键基础设施企业开放,不面向公众发布,普通开发者也无法通过 API 调用

❌
❌