阅读视图

发现新文章,点击刷新页面。

TGA上宣布定档,梁其伟终于把《影之刃零》端上来了

文丨贝果树

编辑丨果脯

今年的TGA上,《光与影:33号远征队》可谓风头无两。它狂揽包括年度最佳游戏在内的9项大奖,成为今年行业内的最大高光点。而B站作为官方直播合作方,在年度最佳游戏公布时刻的“断播”也因此显得相当抢戏。

TGA颁奖典礼

但对中国玩家来说,2025 TGA极其具有记忆点的事情还有两件:一个是《明日方舟:终末地》定档明年1月22日,比苹果官方“剧透”的时间还早了几天。另一个则是前戏做足的《影之刃零》,终于宣布将于明年9月9日上线,并于今天上架Steam商店。

《影之刃零》Steam页面

作为继《黑神话:悟空》后,最受关注的3A级国单,《影之刃零》其实已经吸引了相当多的目光与期望。

今年ChinaJoy、科隆展等大型展会上,《影之刃零》摊位的试玩队伍基本都是人满为患。而官方灵游坊一头持续释放内容,一头也借定档时间进行多次宣发,如他们在科隆展上放出的技术演示,曾被玩家调侃是“预告的预告”。甚至有网友分析其设计难度,推测游戏要真做好说不定得到2027年。

这么一看,《影之刃零》要是2026下半年能准时上线,并且把游戏打磨好,对国单市场而言倒也是比较理想的节点。

自2022年首曝至今,《影之刃零》已经能给玩家提供足够具体的产品形象。它继承了灵游坊一贯的暗黑朋克武侠等,且不继承前三部IP手游的剧情,而是其CEO兼制作人梁其伟首款系列产品“雨血”的续作——或者更严格地说,它传承于梁其伟于2008年推出的RPG《雨血:死镇》。

出走十五载,梁其伟仍想做回当初的少年。

01

2008年,尚在就读大学的梁其伟掏出了一款独自制作的RPG《雨血:死镇》。

这款游戏使用RPGMaker引擎开发,以武侠世界为背景,讲述了主角“魂”被朋友陷害后,被组织追杀的故事。故事开篇,身手重伤的魂为了躲避追杀,找寻阴谋背后的真相,动用秘法恢复身体,结果导致自己生命仅剩六十四天。这种注定走向灭亡的基调,也贯穿了梁其伟后续的多部作品。

《雨血:死镇》

尽管《雨血:死镇》的流程只有4小时,但它所呈现的世界观却吸引了大量玩家的挖掘与创作,也因此收获了大量认可。在游戏点评网站RPGfan上,这款游戏评分高达8.8——不久前发售的《宝可梦ZA》在该网站也只获得了8.1分。

但这里面最激励梁其伟的,更多是那些来自多方态度的转变与现实支持。

因为开发游戏,梁其伟曾受过包括父母、女朋友乃至女朋友父母等人的多方“劝阻”,希望他悬崖勒马。而他自己,也真的因为做游戏挂掉一门课,从而错失了清华建筑系研究生的保送资格。为此,他不得不用硬盘将做到一半的《雨血:死镇》封存。

《雨血》系列草稿

直到后来,他在耶鲁大学课程实在太少,百无聊赖之下想起这事,才重新拿出来开发。

完整的《雨血:死镇》,也让梁其伟接收到了与过去截然不同的社会反馈——看到那些看不懂中文的同学,对着游戏画面大呼“my god”;教授们看到以后,不仅没有劝阻梁其伟,还抓着他大谈东方美学。更夸张的是,后续小半个学院都知道他们这届出了个梁其伟,“Chinese guy making cool games”。

而网络上的反馈更夸张。《雨血:死镇》上传第二天,就收获了超10万的下载量,后续各平台累计下载更是超过了400万次。2010年,他将《雨血:死镇》翻译成英文丢到一些小RPGmaker的论坛后,还有网友主动找过来,希望免费给游戏重写一遍代码语言——当然,最后梁其伟还是坚持给了对方600刀,并在游戏发售后的1小时内赚了回来。

这段经历,不仅让梁其伟有了动力,继续开发制作了第一章的《雨血2:烨城》,也成了他未来成立灵游坊的重要契机。

2011年,当时留学美国的梁其伟因为一些机缘巧合回国,他见了之前做《雨血:死镇》时就帮他做游戏的网友,组建了团队。而出于对梁其伟本人的认可,真格基金创始人徐小平也给了他100万投资。

于是乎,灵游坊就这么成立,并于同年十二月推出了第二款游戏《雨血2:烨城》。

《雨血2:烨城》

到了2013年,灵游坊发布他们的第三部作品,《雨血前传:蜃楼》,相比前作,它们都拥有了更加明显的进步,这个系列从回合制RPG游戏进化为了Unity3D引擎制作的横板动作游戏。梁其伟的创作重心,慢慢开始从“剧情至上”,转变到了钻研游戏性。

《雨血前传:蜃楼》游戏的首发表现还算不错,仅用3个月就突破了30W的销量。同年12月中旬,该游戏的英文单机版本也在仅发行一个月后在Steam平台卖出了13万套。但技术的进步也意味着游戏开发的成本在变大。这时,灵游坊的团队扩展到了7人。

02

此时,梁其伟也意识到,做单机并非长久之计。《雨血前传:蜃楼》30万的销量最终只给灵游坊带来了两三百万的收入,发行商拿了其余的大头,团队实际上处于入不敷出的状态。

一段时间里,梁其伟甚至没法拿出团队的工资,大家开始讨论散伙,怎么才能找到下一份工作。直到这时,理想与现实才开始碰撞,他们第一次开始考虑除了“做一款好游戏”之外的事:市场、管理运营,财务问题,要怎样才能继续生存下去。

幸运的是,同期网易正逢求变时期,丁磊看中了灵游坊的独特气质与创作精神,大手一挥就给其投资了数百万美金——甚至是在梁其伟主动为他们敲警钟的前提下。

他直白地告诉丁磊,之前的PC单机游戏做得再怎么样,其实仍旧不赚钱。进军手游,灵游坊也会采取一贯的研发,可能会错过许多机会。对此,丁磊给他的回应只有“死磕”。

基于这种开发背景,2017年9月,灵游坊推出ARPG手游《影之刃1》。作为一款横板格斗游戏,《影之刃1》继承了雨血系列的武侠特色,凭借独特的美术风格和优良的动作设计收获了许多玩家的喜爱。在2014德国科隆展上,它获得了最佳移动游戏的提名,与任天堂的《全明星大乱斗》同台竞争。

《影之刃1》

更重要的是,《影之刃1》让灵游坊真正意义上赚到了钱。《影之刃1》月流水超4000万,团队不仅不用考虑是否散伙,整体人数还从20人增长到70人。

此后,他们陆续发行了《影之刃2》和《影之刃3》两部续作。《影之刃2》的预算比前作高了十倍,刚上线时就获得了App Store中国区首页十个推荐位。《影之刃3》公测当天,游戏iOS端登顶AppStore角色扮演游戏榜首。在不断提高的预算和技术之下,《影之刃》系列似乎在不断进化,但也伴随着不少玩家的质疑。

因为急于变现,《影之刃2》发售首月表现不错,但随后便迅速下滑。不少玩家质疑这部作品为了追求商业化改变了原作风格。平衡性问题突出,只有氪金才能获得好的游戏体验。而《影之刃3》在开服后,TapTap上的评分也迅速下滑。尽管梁其伟称他们做的是“带着独立精神的商业游戏”,但显然无法靠此说服不满的玩家,不少人坚信他是为了赚钱放弃了独立游戏精神。另一件在玩家群体里被津津乐道的事是,在转型手游并运营不佳后,梁其伟被玩家发现在微博上关注兰博基尼推文并拿出汽车模型,才有了后续说他“赚了钱先买兰博基尼”的声音。

直到他们决定开始做《影之刃零》,玩家的声音逐渐从质疑转变为期待。

一方面,他们在思考后决定继续做自己想做的游戏。另一方面,他们拿到了腾讯的投资。

2021年,灵游坊发生工商信息变更,新增股东腾讯,后者持股25%。在梁其伟发布的内部信中,他说:“腾讯得知了我们制作大型PC/主机游戏的想法,随即前来了解情况。在深入探讨后,腾讯提出在不干涉公司经营,不绑定产品合作,不影响作品创作的前提下,对我们进行资金支持和其它必要的帮助。”而他也透露了拿到这笔投资后的后续计划,在未来五年内,将开发两款以PC/主机为主,使用UE5引擎开发的大型ARPG游戏,其中之一就是今天的《影之刃零》。而又为了能全力开发,他们砍掉了当时计划的另一款游戏《群星守卫》,专注于《影之刃零》的开发。

而《影之刃零》的剧情延续了灵游坊初期的雨血系列。但在那时,《雨血:死镇》还是一款仅由一人开发的独立游戏。《雨血》初期的人物和剧情诞生于他政治课上的草稿纸。梁其伟当时学的是建筑,为了做这款游戏,他选修了程序相关的课程。那些课程其实很简单,但对于没有任何基础的梁其伟来说,很难。最终,他一个人花了三年的时间,做出了这款只能玩几小时的游戏。

从课堂上的草稿纸到《影之刃》手游,再到今天的《影之刃零》,同样的武侠世界一路跌宕起伏,因为坚持独游失利过,为了赚钱妥协过。但最终,《影之刃零》出现了,他还是想做一款单机游戏,捡起自己昔日最初的梦想——更何况,现在做好单机游戏,是真的能挣到钱了。

2008年的《血雨:死镇》里,身负重伤的主角只剩下六十四天的生命,而在十余年后的《影之刃零》中,主角“魂”再次出现在玩家眼前,生命剩下六十六天,故事继续从这里开始,他要在这剩余的时间里,寻找这个江湖和自己的结局。就是不知道,这多出的2天,对魂,对梁其伟,对灵游坊,具体意味着什么。

03

如果只能用一个词形容《影之刃零》,那可能是从独立游戏时期就一直继承下来的“武侠”。

今年7月,《影之刃零》在北京首钢园举行了线下试玩会,36氪游戏也在现场体验了这款国单3A的标杆作品。从游戏玩法、背景设定和战斗机制等方面综合来看,《影之刃零》都有着继《黑神话:悟空》之后第二款标杆级3A游戏的潜力。

这既不是一款动作模组简单低速的魂类游戏,也不是鬼泣那样的高速ACT,而是结合了两者优点的一款ARPG——它想为玩家提供的,是一种属于中国武侠韵律的战斗体验。

而在今天的TGA年度颁奖典礼上,《影之刃零》发布的全新的PV视频,也充斥着“暗黑武侠”的风格。

故事从主角的一段回忆展开,怀抱着婴儿的覆面人出现,一边保护手中的孩子一边抵御一群黑衣人的追杀。随后主角惊醒,乘坐着马车驶向远方。在之后的片段中,也能了解到主角被追杀的现况和一位女性NPC。在PV的后端,也出现了形似在《血雨》系列中就出现过多次的角色冷荼。

《影之刃零》PV

在战斗方面,新PV中也展示了众多新BOSS战以及武器招式。比如在之前的预告中就出现过的舞狮,以跳桩的方式出场,融合中国武术美学和暗黑风格。同时,在PV中也展示了一些新的战斗机制,比如在其中一个BOSS向主角攻击,丢出锁链后,主角可以抓住锁链,并通过连续按击按键完成对BOSS的反击,形式可能类似《怪猎荒野》中大剑的角力。

《影之刃零》PV

在另一些场景中,主角则可以通过引发落石、爆桶对敌人造成伤害。而在PV中,也展现了如长枪、大锤之类的新武器。

在最新上架的Steam页面中,也介绍到《影之刃零》借助了最新的动作捕捉技术,游戏的动作指导是甄子丹“甄家班”的的谷垣健治,曾经担任过《卧龙:苍天陨落》的董总指导。

此前在36氪游戏与制作人梁其伟的交流过程中,我们就有感受到,《影之刃零》团队对中国传统武侠和功夫文化进行了长期且深入的研究,以求更好还原每个角色动作、个性,以及那个血雨腥风的江湖时代。

而在实机体验中,角色的高速动作设计确已与中国武术做了融合,游玩起来像功夫大片一样充满视觉观赏性。结合黄金年代的武侠电影和朋克幻想元素,冷兵器、义肢、武侠秘笈,共同构筑起了《影之刃零》的世界。

《影之刃零》

在《雨血》以及《影之刃零》中,主角要在这剩余六十六天的生命中,找回自己的内心,这个故事似乎也能和灵游坊的发展形成一些互文,他们做了六年单机,七年手游,最后回归单机。他们经历了一开始对独立游戏的热情,也经历过手游时期为了赚钱导致过度商业化,而在《影之刃零》中,他们最后还是想讲一个有头有尾的好故事。

但可能与游戏不同的是,他们最后找回的东西也许也改变了,《影之刃零》还是一款需要赚钱的商业游戏,他们还是需要考虑市场,考虑赚钱。

不过,「做出一款好游戏」依然是灵游坊《影之刃零》团队的核心目标,或许我们能抱着这样的期待,等待《影之刃零》发售的那天。

《影之刃零》

本文首发自“36氪游戏”

告别字体闪烁 / 首屏卡顿!preload 让关键资源 “高优先级” 提前到

⚡️ 浏览器“未卜先知”的秘密:资源提示符,让你的页面加载速度快人一步!

前端性能优化专栏 - 第四篇

在前端性能优化的战场上,时间就是金钱,尤其是在页面加载的关键时刻。我们上一篇讲到 PerformanceObserver 可以精准地测量性能,但测量只是第一步,更重要的是主动出击,让浏览器在用户需要资源之前,就提前做好准备。

今天,我们就来揭秘浏览器“未卜先知”的秘密武器——资源提示符(Resource Hints)


💡 什么是资源提示符?

资源提示符(Resource Hints)是 <link> 标签 rel 属性的一组特殊值,用于告诉浏览器未来即将发生的资源处理策略,让它提前做准备

简单来说,它们是开发者给浏览器下达的“预处理指令”,让浏览器在空闲或关键时刻,提前完成一些耗时的网络操作,从而:

  • 提高网页的首屏加载性能
  • 减少 DNS、TCP、TLS 等连接延迟
  • 预加载关键或预测性资源
<!-- 资源提示符示例 -->
<link rel="preconnect" href="//cdn.example.com">

🔧 四大金刚:资源提示符的家族成员

资源提示符家族主要有四个核心成员,它们各有神通,针对不同的优化场景:

1. dns-prefetch:最小开销的“打听”

<link rel="dns-prefetch" href="//api.example.com">
  • 作用: 仅提前解析 DNS,将域名解析为 IP 地址,不建立连接

  • 开销: 最小,兼容性最好。

  • 使用场景:

    • 非关键的第三方资源(如分析脚本、广告、插件)。
    • 可作为 preconnect降级方案

专业名词解释:DNS 解析 DNS(Domain Name System)解析是将人类可读的域名(如 www.google.com)转换为机器可读的 IP 地址(如 142.250.190.14)的过程。这是一个网络请求的起点,通常需要几十到几百毫秒。

2. preconnect:提前握手的“老朋友”

<link rel="preconnect" href="//cdn.example.com" crossorigin>
  • 作用: 完成 DNS 解析 + TCP 握手 + TLS 加密握手,全流程建立连接。

  • 效果: 极大地消除了后续资源请求的网络延迟。

  • 使用时机:

    • 字体库核心 APICDN 静态资源关键第三方域名
    • 注意: 建立连接会消耗资源,建议控制数量(一般建议 ≤6 个)。

Preconnect 提前握手过程示意图

3. preload:高优先级的“快递”

<link rel="preload" href="font.woff2" as="font" type="font/woff2" crossorigin>
  • 作用: 直接以高优先级下载关键资源,但下载后暂不执行

  • 特点: 提前触发关键资源的加载,确保资源在需要时立即可用。

  • 常见场景:

    • CSS 定义的字体文件(避免文本闪烁 FOUT/FOIT)。
    • 背景图或 LCP 元素图片(加速最大内容绘制)。
    • 首屏必需的动态脚本

注意: preload 必须配合 as 属性指定资源类型,否则浏览器会重复下载。

4. prefetch:空闲时的“下一站”

<link rel="prefetch" href="next-page.js">
  • 作用:当前页加载完成后,利用浏览器空闲时间请求资源。

  • 特点: 优先级最低,不会与当前页面的关键资源竞争带宽。

  • 使用场景:

    • 优化“下一个页面”的加载体验
    • SPA 路由中,预取用户可能访问的下一个 chunk
    • 基于用户行为预测的预加载。

💡 总结:让资源“早一步”准备好

资源提示符家族的目标一致:让资源“早一步”准备好

它们的核心区别在于时机与深度

提示符 深度(提前到哪一步) 时机(何时触发) 优先级 适用场景
dns-prefetch 仅 DNS 解析 尽早 非关键第三方资源
preconnect DNS + TCP + TLS 尽早 关键第三方域名
preload 下载资源 尽早(高优先级) 当前页面的关键资源
prefetch 下载资源 页面空闲时 最低 下一个页面的资源

资源提示符概览图

重要提醒: 资源提示符虽好,但过度使用可能导致浪费带宽或建立过多连接,反而拖慢性能。请务必根据实际的性能数据(比如 RUM 采集的数据)来合理规划和使用。


下一篇预告: 既然资源都提前加载了,如何让它们在下次访问时更快出现呢?下一篇我们将深入探讨前端性能优化的“节流大师”——HTTP 缓存机制。敬请期待!

性能数据别再瞎轮询了!PerformanceObserver 异步捕获 LCP/CLS,不卡主线程

🚀 性能监控的“最强大脑”:PerformanceObserver API,如何让你告别轮询的噩梦?

前端性能优化专栏 - 第三篇

在上一篇中,我们聊到了 RUM(真实用户监控)是如何帮助我们打破“薛定谔的 Bug”魔咒的。既然 RUM 是性能监控的“雷达”,那么谁来负责实时、精准地采集数据呢?

答案就是今天的主角——PerformanceObserver API。它就像是浏览器内置的“高性能数据采集器”,彻底改变了我们获取性能数据的方式。


⚠️ 为什么需要 PerformanceObserver?告别“老黄历”

在 PerformanceObserver 出现之前,我们获取性能数据的方式,简直就是一场“噩梦”:

传统方式:性能监控的“老黄历”

  1. performance.timingperformance.getEntries()

    • 问题: 这些 API 只能获取页面加载完成那一刻的静态数据。对于像 First Input Delay (FID) 这种发生在用户交互过程中的动态指标,它们就无能为力了。
    • 痛点: 想要获取实时数据?你只能轮询(不断地去问:“数据好了吗?好了吗?”)。这种方式不仅时机难以掌握,还会带来额外的性能开销,甚至可能阻塞主线程,让页面更卡!

专业名词解释:轮询 (Polling) 轮询是一种计算机通信技术,指客户端程序或设备不断地向服务器程序或设备发送请求,以查询是否有新的数据或状态更新。在前端性能监控中,轮询意味着需要定时检查性能数据是否生成,效率低下且消耗资源。

✨ 优化方案:事件驱动的“高性能引擎”

PerformanceObserver 的出现,彻底解决了轮询的痛点。它提供了一种事件驱动、异步回调的机制:

  • 高效、非阻塞: 它在浏览器记录到性能事件时,会异步通知你,不会阻塞主线程。
  • 实时性: 能够实时捕获动态指标,如用户首次输入延迟(FID)和布局偏移(CLS)。
  • 可订阅: 你可以像订阅报纸一样,选择你感兴趣的性能事件类型。

🔄 PerformanceObserver 的工作原理:三步走战略

PerformanceObserver 的使用流程非常简洁,可以概括为“创建、指定、接收”三步走战略:

步骤 1:创建观测器(Observer)

首先,我们需要创建一个 PerformanceObserver 实例,并传入一个回调函数 (callback)

const observer = new PerformanceObserver((list) => {
  // 浏览器在记录到性能条目时,会自动异步触发这个回调函数
  // list.getEntries() 包含了所有被观测到的性能数据
})

工作原理揭秘: 浏览器在内部记录性能数据时,会检查是否有 PerformanceObserver 在监听。如果有,它就会将最新的性能条目(Performance Entry)打包,并在下一个空闲时机(异步)调用你提供的回调函数。

步骤 2:指定观测目标(Observe)

创建好观测器后,你需要明确告诉它:“我想看哪些数据? ” 这通过 observer.observe() 方法实现,你需要指定一个或多个 entryTypes

observer.observe({
  entryTypes: ['largest-contentful-paint', 'first-input', 'layout-shift']
})

常见的核心观测指标:

entryType 对应指标 含义
largest-contentful-paint LCP 最大内容绘制时间,衡量加载速度。
first-input FID 首次输入延迟,衡量交互响应速度。
layout-shift CLS 累积布局偏移,衡量视觉稳定性。
resource Resource Timing 资源加载(图片、CSS、JS)的详细耗时。

PerformanceObserver 与传统方式对比图

步骤 3:接收和处理数据(Callback)

在回调函数中,你可以通过 list.getEntries() 获取到所有新产生的性能条目。每个条目(Entry)都是一个包含详细信息的对象。

示例:基础用法

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log('指标名称:', entry.name)
    console.log('开始时间:', entry.startTime)
    console.log('持续时间:', entry.duration)

    // 针对不同指标进行特殊处理,例如获取 CLS 的具体值
    if (entry.entryType === 'layout-shift') {
      console.log('CLS 值:', entry.value)
    }
    // 在这里将数据上报到 RUM 服务器
  }
})

observer.observe({
  entryTypes: ['largest-contentful-paint', 'first-input', 'layout-shift']
})

总结:PerformanceObserver 的核心优势

PerformanceObserver 是前端性能监控领域的一次重大飞跃,它的核心优势在于:

  • 实时性: 事件驱动,性能数据一产生就能被捕获,无需低效的轮询。
  • 低开销: 异步执行,不占用主线程资源,对用户体验影响极小。
  • 可扩展: 通过 entryTypes,可以轻松订阅未来浏览器新增的各种性能事件。
  • 易集成: 它是现代 RUM 监控体系中,最核心、最可靠的数据采集组件。

结论: PerformanceObserver 是构建前端性能可观测性的核心组件,它让我们从“猜测性能”迈向了 “数据驱动的性能优化” ,让性能数据采集变得高效、优雅。


下一篇预告: 既然我们能精准地测量性能了,下一步就是如何主动出击,让浏览器提前加载资源。下一篇我们将深入讲解前端性能优化的“预加载神器”——浏览器资源提示符。敬请期待!

GDAL 读取KML数据

前言

KML是一种基于XML的地理数据格式,最初有Keyhole公司开发,后来被Google采用并成为OGC标准。在GIS开发中,属于一种重要的数据格式,使用GDAL读取KML数据,有助于认识、了解KML数据结构与特点,从而提高开发效率。

本篇教程在之前一系列文章的基础上讲解

  • GDAL 简介[1]
  • GDAL 下载安装[2]
  • GDAL 开发起步[3]

如果你还没有看过,建议从以上内容开始。

1. 开发环境

本文使用如下开发环境,以供参考。

时间:2025年

系统:Windows 11

Python:3.11.7

GDAL:3.11.1

2. 导入依赖

KML作为一种矢量数据格式,可以使用GDAL直接读取或者使用其矢量库OGR进行处理,以实现KML图层和属性数据读取。

from osgeo import ogr,gdal
import os

3. 读取KML数据

(一)使用GDAL读取

定义一个方法ReadKMLOfGDAL(kmlPath)用于读取KML数据,其中kmlPath为数据路径。在读取KML数据之前,需要检查数据路径是否存在。

# 检查文件是否存在
if os.path.exists(kmlPath):
    print("文件存在")
else:
    print("文件不存在,请重新选择文件!")
    return

KML数据路径正确,则可以使用OpenEx方法打开KML文件。需要判断KML数据集是否正常,若无法打开,则退出数据读取程序。

# 打开KML文件
dataset = gdal.OpenEx(kmlPath)
if dataset is None:
    print("KML 文件打开异常,请检查文件路径!")
    return

通过数据集方法GetLayerCount可以获取图层数量。

# 获取图层数量
layerCount = dataset.GetLayerCount()
print(f"图层数量:{layerCount}")

图层数量信息显示如下:

之后通过遍历图层获取图层字段数量、字段名称以及字段类型等信息,在输出结果中读取要素属性信息和几何对象并限制要素输出数量。

# 遍历图层
for i in range(layerCount):
    print(f"################开始打印第【{i+1}】个图层################n")
    # 根据索引获取目标图层
    layer = dataset.GetLayerByIndex(i)
    # 获取图层名称
    layerName = layer.GetName()
    # 获取图层要素数量
    layerFeatureCount = layer.GetFeatureCount()

    print(f"图层名称:{layerName}")
    print(f"要素数量:{layerFeatureCount}")

    # 获取图层属性
    layerProperty = layer.GetLayerDefn()
    # 获取图层字段数量
    fieldCount = layerProperty.GetFieldCount()
    print(f"字段数量:{fieldCount}")

    # 获取字段信息
    for j in range(fieldCount):
        # 获取字段属性对象
        fieldProperty = layerProperty.GetFieldDefn(j)
        # 获取字段属性名称
        fieldName = fieldProperty.GetName()
        # 获取字段属性类型
        fieldType = fieldProperty.GetTypeName()

        print(f"第 【{j}】 个字段名称:{fieldName},字段类型:{fieldType}")

    # 获取要素
    feature = layer.GetNextFeature()
    limitCount = 0

    # 限制打印前十个要素
    while feature and limitCount < 10:
        print(f"打印第【{limitCount+1}】个要素")
        # print(f"打印要素类型:{type(feature)},{feature}")

        # 读取要素属性
        for k in range(fieldCount):
            # 属性字段名
            fieldName = layerProperty.GetFieldDefn(j).GetName()
            # 属性字段值
            fieldValue = feature.GetField(k)
            # fieldValue = feature.GetField(fieldName)

            print(f"第 【{k}】 个字段名:{fieldName},字段值:{fieldValue}")

        # 读取几何属性
        geom = feature.GetGeometryRef()
        if geom:
            # 获取几何类型
            geomType = geom.GetGeometryName()
            # 获取WKT格式几何对象,打印前100个字符
            geomWKT = geom.ExportToWkt()[:100]

            print(f"第 【{limitCount}】 个几何对象类型:{geomType},几何对象:{geomWKT}")

        feature = layer.GetNextFeature()
        limitCount += 1

    # 重置读取位置
    layer.ResetReading()

    print(f"n################结束打印第【{i+1}】个图层################n")

图层要素属性信息显示如下:

(二)使用OGR读取

定义一个方法ReadKMLOfOGR(kmlPath)用于读取KML数据,其中kmlPath为数据路径。在读取KML数据之前,需要检查数据路径是否存在。

# 检查文件是否存在
if os.path.exists(kmlPath):
    print("文件存在")
else:
    print("文件不存在,请重新选择文件!")
    return

KML数据路径正确,则可以注册KML数据驱动用于读取KML数据,如使用RegisterAll方法注册所有矢量驱动。然后调用ogr对象Open方法打开KML数据源,若其不存在,则退出数据读取程序。

# 注册所有驱动
ogr.RegisterAll()

# 打开KML数据源
dataSource = ogr.Open(kmlPath)

# 检查数据源是否正常
if dataSource is None:
    print("文件打开出错,请重新选择文件!")
    return

之后通过遍历图层获取图层空间参考、字段名称以及字段类型等信息,在输出结果中读取要素属性信息。

# 遍历图层
for i in range(dataSource.GetLayerCount()):
    # 根据索引获取目标图层
    layer = dataSource.GetLayer(i)
    # 获取图层名称
    layerName = layer.GetName()
    print(f"第【{i}】个图层名称:{layerName}")

    # 获取空间参考
    spatialReference = layer.GetSpatialRef()
    if spatialReference:
        print(f"空间参考:{spatialReference.GetName()}")
    else:
        print(f"图层【{layerName}】空间参考不存在")

    # 读取几何属性
    for feature in layer:
        # 读取几何属性
        geom = feature.GetGeometryRef()
        if geom:
            # 获取四至范围
            envelope = geom.GetEnvelope()
            print(f"几何范围:{envelope}")

        # 读取要素属性
        for field in feature.keys():
            # 获取属性字段值
            fieldValue = feature.GetField(field)
            print(f"属性字段名称:{field},属性字段值:{fieldValue}")

# 关闭数据源
dataSource = None        

图层要素属性信息显示如下:

4. 注意事项

注1:数据路径读取异常

在windows系统中建议使用"\"定义数据路径。

注2:中文数据读取异常(中文乱码)

GIS开发中,涉及属性数据读取时经常会遇到中文乱码问题,需要根据图层编码设置正确的字符集。

# 设置Shapefile的编码为GBK
os.environ['SHAPE_ENCODING'] = "GBK"

注3:代码运行异常

需要开启代码异常处理

# 启用异常处理(推荐)
ogr.UseExceptions()

注4:坐标读取异常

在读取坐标参考时报错已安装PostgreSQL数据库中的投影文件版本与GDAL中的投影文件不兼容,此时需要为GDAL单独指定投影文件,在代码开头添加以下代码指定目标投影文件路径。

# 找到proj文件路径
os.environ['PROJ_LIB'] = r'D:\Programs\Python\Python311\Lib\site-packages\osgeo\data\proj'

5. 完整代码

from osgeo import ogr,gdal
import os

# 如果是通过 pip 安装的,可能需要找到对应位置
os.environ['PROJ_LIB'] = r'D:ProgramsPythonPython311Libsite-packagesosgeodataproj'

# 设置Shapefile的编码为GBK
os.environ['SHAPE_ENCODING'] = "GBK"

# 启用异常处理(推荐)
ogr.UseExceptions()

# 注册所有驱动
ogr.RegisterAll()

"""
使用GDAL读取KML数据
"""
def ReadKMLOfGDAL(kmlPath):

    # 检查文件是否存在
    if os.path.exists(kmlPath):
        print("文件存在")
    else:
        print("文件不存在,请重新选择文件!")
        return

    # 打开KML文件
    dataset = gdal.OpenEx(kmlPath)
    if dataset is None:
        print("KML 文件打开异常,请检查文件路径!")
        return

    # 获取图层数量
    layerCount = dataset.GetLayerCount()
    print(f"图层数量:{layerCount}")

    # 遍历图层
    for i in range(layerCount):
        print(f"################开始打印第【{i+1}】个图层################n")
        # 根据索引获取目标图层
        layer = dataset.GetLayerByIndex(i)
        # 获取图层名称
        layerName = layer.GetName()
        # 获取图层要素数量
        layerFeatureCount = layer.GetFeatureCount()

        print(f"图层名称:{layerName}")
        print(f"要素数量:{layerFeatureCount}")

        # 获取图层属性
        layerProperty = layer.GetLayerDefn()
        # 获取图层字段数量
        fieldCount = layerProperty.GetFieldCount()
        print(f"字段数量:{fieldCount}")

        # 获取字段信息
        for j in range(fieldCount):
            # 获取字段属性对象
            fieldProperty = layerProperty.GetFieldDefn(j)
            # 获取字段属性名称
            fieldName = fieldProperty.GetName()
            # 获取字段属性类型
            fieldType = fieldProperty.GetTypeName()

            print(f"第 【{j}】 个字段名称:{fieldName},字段类型:{fieldType}")

        # 获取要素
        feature = layer.GetNextFeature()
        limitCount = 0

        # 限制打印前十个要素
        while feature and limitCount < 10:
            print(f"打印第【{limitCount+1}】个要素")
            # print(f"打印要素类型:{type(feature)},{feature}")

            # 读取要素属性
            for k in range(fieldCount):
                # 属性字段名
                fieldName = layerProperty.GetFieldDefn(j).GetName()
                # 属性字段值
                fieldValue = feature.GetField(k)
                # fieldValue = feature.GetField(fieldName)

                print(f"第 【{k}】 个字段名:{fieldName},字段值:{fieldValue}")

            # 读取几何属性
            geom = feature.GetGeometryRef()
            if geom:
                # 获取几何类型
                geomType = geom.GetGeometryName()
                # 获取WKT格式几何对象,打印前100个字符
                geomWKT = geom.ExportToWkt()[:100]

                print(f"第 【{limitCount}】 个几何对象类型:{geomType},几何对象:{geomWKT}")

            feature = layer.GetNextFeature()
            limitCount += 1

        # 重置读取位置
        layer.ResetReading()

        print(f"n################结束打印第【{i+1}】个图层################n")

"""
使用OGR读取KML数据
"""
def ReadKMLOfOGR(kmlPath):

    # 检查文件是否存在
    if os.path.exists(kmlPath):
        print("文件存在")
    else:
        print("文件不存在,请重新选择文件!")
        return
    # 注册所有驱动
    ogr.RegisterAll()

    # 打开KML数据源
    dataSource = ogr.Open(kmlPath)

    # 检查数据源是否正常
    if dataSource is None:
        print("文件打开出错,请重新选择文件!")
        return

    # 遍历图层
    for i in range(dataSource.GetLayerCount()):
        # 根据索引获取目标图层
        layer = dataSource.GetLayer(i)
        # 获取图层名称
        layerName = layer.GetName()
        print(f"第【{i}】个图层名称:{layerName}")

        # 获取空间参考
        spatialReference = layer.GetSpatialRef()
        if spatialReference:
            print(f"空间参考:{spatialReference.GetName()}")
        else:
            print(f"图层【{layerName}】空间参考不存在")

        # 读取几何属性
        for feature in layer:
            # 读取几何属性
            geom = feature.GetGeometryRef()
            if geom:
                # 获取四至范围
                envelope = geom.GetEnvelope()
                print(f"几何范围:{envelope}")

            # 读取要素属性
            for field in feature.keys():
                # 获取属性字段值
                fieldValue = feature.GetField(field)
                print(f"属性字段名称:{field},属性字段值:{fieldValue}")

    # 关闭数据源
    dataSource = None        

if __name__ == "__main__":

    # 数据路径
    kmlPath = "E:\data\test_data\四姑娘山三峰.kml"

    # GDAL读取KML数据
    ReadKMLOfGDAL(kmlPath)

    # OGR读取KML数据
    ReadKMLOfOGR(kmlPath)

6. KML示例数据

<?xml version="1.0" encoding="utf-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2">
  <Document>
    <name>Track 登顶四姑娘山三峰 :wikiloc.com</name>
    <visibility>1</visibility>
    <LookAt>
      <longitude>102.8793075</longitude>
      <latitude>31.0426283</latitude>
      <altitude>0</altitude>
      <heading>3</heading>
      <tilt>66</tilt>
      <range>15000</range>
    </LookAt>
    <StyleMap id="m1367020">
      <Pair>
        <key>normal</key>
        <styleUrl>#n1367020</styleUrl>
      </Pair>
      <Pair>
        <key>highlight</key>
        <styleUrl>#h1367020</styleUrl>
      </Pair>
    </StyleMap>
    <Style id="h1367020">
      <IconStyle>
        <Icon>
          <href>http://s1.wklcdn.com/wikiloc/images/pictograms/ge/1.png</href>
        </Icon>
      </IconStyle>
      <BalloonStyle>
        <text>$[description]</text>
      </BalloonStyle>
    </Style>
    <Style id="lineStyle">
      <LineStyle>
        <color>f03399ff</color>
        <width>4</width>
      </LineStyle>
    </Style>
    <Style id="n1367020">
      <LabelStyle>
        <scale>0</scale>
      </LabelStyle>
      <BalloonStyle>
        <text>$[description]</text>
      </BalloonStyle>
      <Icon>
        <href>http://s1.wklcdn.com/wikiloc/images/pictograms/ge/1.png</href>
      </Icon>
    </Style>
    <Style id="waypointStyle">
      <IconStyle>
        <Icon>
          <href>http://sc.wklcdn.com/wikiloc/images/pictograms/ge/wpt.png</href>
        </Icon>
      </IconStyle>
      <BalloonStyle>
        <text>$[description]</text>
      </BalloonStyle>
    </Style>
    <Folder>
      <name>Trails</name>
      <visibility>1</visibility>
      <Folder>
        <name>登顶四姑娘山三峰</name>
        <visibility>1</visibility>
        <Placemark>
          <name>Path</name>
          <visibility>1</visibility>
          <LookAt>
            <longitude>102.8862617</longitude>
            <latitude>31.052715</latitude>
            <altitude>0</altitude>
            <heading>0</heading>
            <tilt>0.00779126500014642</tilt>
            <range>5250.96911517065</range>
          </LookAt>
          <Style>
            <IconStyle>
              <color>ffffffff</color>
              <scale>1</scale>
              <Icon>
                <href/>
              </Icon>
            </IconStyle>
            <LabelStyle>
              <color>ffffffff</color>
              <scale>1</scale>
            </LabelStyle>
            <LineStyle>
              <color>f00000ff</color>
              <width>4</width>
            </LineStyle>
            <PolyStyle>
              <color>ffffffff</color>
              <fill>1</fill>
              <outline>1</outline>
            </PolyStyle>
          </Style>
          <LineString>
            <altitudeMode>clampToGround</altitudeMode>
            <coordinates>
              102.8527267,31.0061667,3255.400146
              102.8530967,31.00604,3254.899902
              102.8537967,31.0060883,3256.899902
              102.8547817,31.0064133,3270.100098
              102.8558183,31.0071067,3271.100098
              102.8575333,31.00785,3271.699951
              102.8588867,31.0093867,3278.899902
              102.8599,31.0099067,3281.5
              102.8605217,31.01093,3289.899902
              102.8613217,31.0128967,3298.899902
              102.863045,31.014905,3307.199951
              102.8638983,31.016515,3313.100098
              102.8639067,31.01642,3306.699951
              102.86423,31.0168667,3317.199951
              102.8645867,31.017765,3330.000244
              102.8655283,31.0190083,3314.100342
              102.86643,31.0211683,3324.100098
              102.8665367,31.0217183,3321.300049
              102.86754,31.0228467,3328.399902
              102.8682333,31.023345,3331.699951
              102.868495,31.02422,3338.399902
              102.86873,31.0245367,3336.199951
              102.8697533,31.0251667,3343.100098
              102.870035,31.0256033,3345.800049
              102.86997,31.02594,3350.099854
              102.870195,31.0265117,3357.800049
              102.8706917,31.0273617,3360.300049
              102.8717183,31.0284717,3374
              102.8735067,31.0298317,3377.699951
              102.8744233,31.0310767,3382.300049
              102.8748283,31.0321567,3378.699951
              102.8747833,31.0328433,3391.800049
              102.8756183,31.0336933,3406.399902
              102.875455,31.034915,3408
              102.8754967,31.0361467,3406.399902
              102.8759333,31.037405,3412
              102.8763117,31.0379283,3415.999756
              102.87597,31.0385567,3416.199951
              102.8757067,31.0415767,3399.100098
              102.87552,31.0419067,3415.999756
              102.8758433,31.0423217,3424.100098
              102.8762517,31.0425117,3439.200195
              102.8762617,31.04284,3444
              102.8764567,31.0430117,3450.199951
              102.8766917,31.0436783,3461.399902
              102.8771717,31.0439417,3481.399902
              102.876935,31.04407,3486.899902
              102.8771133,31.04414,3494.399902
              102.8772133,31.0444317,3502.300049
              102.8782383,31.0450583,3541.100098
              102.878835,31.045955,3559.100098
              102.8790667,31.0470883,3574.699951
              102.8792533,31.0472867,3574.5
              102.8790733,31.04746,3574.199951
              102.8791133,31.0475933,3575.300049
              102.879595,31.0479917,3586
              102.8803283,31.0490267,3626.399902
              102.8804683,31.0489483,3627.600098
              102.880595,31.049135,3626.800049
              102.8807983,31.0491317,3629.199951
              102.8807333,31.0493933,3629.800049
              102.88088,31.04944,3629.100098
              102.880855,31.049585,3628.699951
              102.8811167,31.0496783,3629
              102.8812417,31.049575,3629.600098
              102.8814083,31.049755,3632.600098
              102.881335,31.0500367,3634.5
              102.8811333,31.0499417,3638.800049
              102.88138,31.05021,3638.699951
              102.8812683,31.0501417,3639
              102.8813417,31.0499933,3637.499756
              102.8813383,31.0501217,3642.600098
              102.8822067,31.050155,3652.599854
              102.8823317,31.050305,3655.699951
              102.8827433,31.0501883,3663.399902
              102.882945,31.0503983,3691
              102.8835383,31.0504067,3708.600098
              102.883635,31.0504717,3713.199707
              102.88357,31.0509167,3720.699951
              102.8834217,31.0509483,3723.000244
              102.8837983,31.0511317,3728.600342
              102.8841217,31.0509617,3733
              102.8840783,31.0516483,3760.400146
              102.8844567,31.0517517,3780.399902
              102.8844183,31.0518767,3795.699951
              102.884775,31.0518117,3818.499756
              102.8848583,31.0522,3863
              102.885575,31.051965,3896.800049
              102.88583,31.05217,3908.600098
              102.885545,31.0519417,3948.100098
              102.88575,31.0519467,3951.500244
              102.8857867,31.0521417,3960.899902
              102.8861367,31.0522567,3973.300293
              102.8862617,31.052715,3985.5
              102.8865033,31.0528033,3996.699707
              102.8865233,31.0531233,4007.399902
              102.886855,31.053565,4025.600098
              102.8878733,31.0542133,4081.300049
              102.888465,31.0543383,4096.399902
              102.8887633,31.05476,4105.5
              102.8889883,31.0546883,4115.200195
              102.8891233,31.0549117,4131
              102.8893483,31.0548067,4143.200195
              102.8900367,31.055275,4164.200195
              102.8902983,31.0563283,4190.399902
              102.8902633,31.0578033,4191.899902
              102.890535,31.05789,4203.200195
              102.89051,31.058235,4225.799805
              102.8909267,31.0584983,4262.799805
              102.8911817,31.05891,4273.899902
              102.8913883,31.05877,4285
              102.8913233,31.0584617,4289.399902
              102.89199,31.0583817,4299
              102.8919,31.058545,4308.200195
              102.8920433,31.05873,4319.299805
              102.8924917,31.05891,4352
              102.8927133,31.0588033,4365.200195
              102.8930267,31.059215,4373.200195
              102.89327,31.0590433,4388.899902
              102.8934967,31.0592717,4391.299805
              102.8934583,31.0594417,4395.899902
              102.8937567,31.0595283,4406.299805
              102.8940683,31.0601267,4421
              102.8943233,31.06027,4429.5
              102.8943667,31.0605067,4435.600098
              102.8941,31.0606483,4444
              102.89444,31.0607917,4452.799805
              102.89331,31.0618433,4485.899902
              102.893345,31.061985,4489.799805
              102.8938833,31.0621483,4498.399902
              102.8937483,31.0619783,4499
              102.89363,31.0620033,4499.399902
              102.8937967,31.062175,4499.799805
              102.8943467,31.0621867,4503.899902
              102.8943433,31.062095,4504.700195
              102.8943767,31.0622417,4504.5
              102.8948533,31.062295,4503.600098
              102.8957933,31.0629667,4506.299805
              102.8959517,31.0628633,4506.399902
              102.89649,31.0635683,4509.799805
              102.8966483,31.063565,4509.399902
              102.8967717,31.0639033,4511.600098
              102.8974033,31.0641033,4518.100098
              102.8982783,31.0652517,4530.399902
              102.8985533,31.0661067,4556.299805
              102.899115,31.0666583,4589.600098
              102.8990783,31.0670983,4620.700195
              102.8994317,31.0674483,4636
              102.8997217,31.068335,4650.799805
              102.9004533,31.0686783,4657.799805
              102.90056,31.0690317,4672.100098
              102.9008217,31.069215,4664.5
              102.9005883,31.0696883,4677.399902
              102.9007033,31.0700017,4692.100098
              102.9013133,31.070325,4701.100098
              102.9020567,31.0710117,4716.899902
              102.902175,31.0713983,4738.899902
              102.9026167,31.0719533,4748
              102.903125,31.0721467,4758.299805
              102.9036383,31.0726467,4757.299805
              102.9035233,31.072715,4757.399902
              102.9036517,31.0728533,4759.5
              102.9047917,31.0735717,4823.5
              102.905155,31.07431,4862.299805
              102.9062583,31.0745867,4891.799805
              102.9065483,31.07534,4962.100098
              102.906415,31.075375,4966
              102.906495,31.0755583,4993.700195
              102.9062583,31.0755983,4994.899902
              102.9066633,31.0755817,4990.700195
              102.9064633,31.0757367,5003.000488
              102.9069417,31.0759117,5031.500488
              102.9069833,31.0760817,5034.899902
              102.9068167,31.076175,5040.100098
              102.9069583,31.0762483,5041.700195
              102.9070367,31.0766883,5058.5
              102.906675,31.0769033,5078.899902
              102.906895,31.0768783,5081.200195
              102.90672,31.0772267,5096.200195
              102.9071467,31.0774933,5137.5
              102.9072017,31.07771,5142.200195
              102.90558,31.0791683,5322.200195
              102.905505,31.0793567,5341.899902
              102.905815,31.0797233,5358.100098
              102.9054383,31.07938,5345.500488
              102.9055167,31.07932,5349.5
              102.90543,31.0794,5349.100098
            </coordinates>
          </LineString>
        </Placemark>
      </Folder>
    </Folder>
  </Document>
</kml>

OpenLayers示例数据下载,请回复关键字:ol数据

全国信息化工程师-GIS 应用水平考试资料,请回复关键字:GIS考试

【GIS之路】 已经接入了智能助手,欢迎关注,欢迎提问。

欢迎访问我的博客网站-长谈GIShttp://shanhaitalk.com

都看到这了,不要忘记点赞、收藏 + 关注

本号不定时更新有关 GIS开发 相关内容,欢迎关注 !

iOS SwiftUI 布局容器详解

SwiftUI 布局容器详解

SwiftUI 提供了多种布局容器,每种都有特定的用途和行为。以下是对主要布局容器的全面详解:

一、基础布局容器

1. VStack - 垂直堆栈

VStack(alignment: .leading, spacing: 10) {
    Text("顶部")
    Text("中部")
    Text("底部")
}
  • 功能:垂直排列子视图
  • 参数
    • alignment:水平对齐方式(.leading, .center, .trailing
    • spacing:子视图间距
  • 布局特性:根据子视图大小决定自身高度

2. HStack - 水平堆栈

HStack(alignment: .top, spacing: 20) {
    Text("左")
    Text("中")
    Text("右")
}
  • 功能:水平排列子视图
  • 参数
    • alignment:垂直对齐方式(.top, .center, .bottom, .firstTextBaseline, .lastTextBaseline
    • spacing:子视图间距

3. ZStack - 重叠堆栈

ZStack(alignment: .topLeading) {
    Rectangle()
        .fill(Color.blue)
        .frame(width: 200, height: 200)
    
    Text("覆盖文本")
        .foregroundColor(.white)
}
  • 功能:子视图重叠排列
  • 参数
    • alignment:对齐方式,控制所有子视图的共同对齐点
  • 渲染顺序:后添加的视图在上层

二、惰性布局容器(Lazy Containers)

4. LazyVStack - 惰性垂直堆栈

ScrollView {
    LazyVStack(pinnedViews: .sectionHeaders) {
        ForEach(0..<1000) { index in
            Text("行 \(index)")
                .frame(height: 50)
        }
    }
}
  • 特性:仅渲染可见区域的视图,提高性能
  • 参数
    • pinnedViews:固定视图(.sectionHeaders, .sectionFooters
  • 使用场景:长列表,性能敏感场景

5. LazyHStack - 惰性水平堆栈

ScrollView(.horizontal) {
    LazyHStack {
        ForEach(0..<100) { index in
            Text("列 \(index)")
                .frame(width: 100)
        }
    }
}

6. LazyVGrid - 惰性垂直网格

let columns = [
    GridItem(.fixed(100)),
    GridItem(.flexible()),
    GridItem(.adaptive(minimum: 50))
]

ScrollView {
    LazyVGrid(columns: columns, spacing: 10) {
        ForEach(0..<100) { index in
            Color.blue
                .frame(height: 100)
                .overlay(Text("\(index)"))
        }
    }
    .padding()
}
  • GridItem类型
    • .fixed(CGFloat):固定宽度
    • .flexible(minimum:, maximum:):灵活宽度
    • .adaptive(minimum:, maximum:):自适应,尽可能多放置

7. LazyHGrid - 惰性水平网格

let rows = [GridItem(.fixed(100)), GridItem(.fixed(100))]

ScrollView(.horizontal) {
    LazyHGrid(rows: rows, spacing: 20) {
        ForEach(0..<50) { index in
            Color.red
                .frame(width: 100)
        }
    }
}

三、特殊布局容器

8. ScrollView - 滚动视图

ScrollView(.vertical, showsIndicators: true) {
    VStack {
        ForEach(0..<50) { index in
            Text("项目 \(index)")
                .frame(maxWidth: .infinity)
                .padding()
                .background(Color.gray.opacity(0.2))
        }
    }
}
  • 滚动方向.vertical, .horizontal
  • 参数
    • showsIndicators:是否显示滚动条

9. List - 列表

List {
    Section(header: Text("第一部分")) {
        ForEach(1..<5) { index in
            Text("行 \(index)")
        }
    }
    
    Section(footer: Text("结束")) {
        ForEach(5..<10) { index in
            Text("行 \(index)")
        }
    }
}
.listStyle(.insetGrouped)  // 多种样式可选
  • 样式.plain, .grouped, .insetGrouped, .sidebar
  • 特性:自带滚动、优化性能、支持分节

10. Form - 表单

Form {
    Section("个人信息") {
        TextField("姓名", text: $name)
        DatePicker("生日", selection: $birthday)
    }
    
    Section("设置") {
        Toggle("通知", isOn: $notifications)
        Slider(value: $volume, in: 0...1)
    }
}
  • 特性:自动适配平台样式,适合设置界面

11. NavigationStack (iOS 16+) - 导航栈

NavigationStack(path: $path) {
    List {
        NavigationLink("详情", value: "detail")
        NavigationLink("设置", value: "settings")
    }
    .navigationDestination(for: String.self) { value in
        switch value {
        case "detail":
            DetailView()
        case "settings":
            SettingsView()
        default:
            EmptyView()
        }
    }
}

12. TabView - 标签视图

TabView {
    HomeView()
        .tabItem {
            Label("首页", systemImage: "house")
        }
        .tag(0)
    
    ProfileView()
        .tabItem {
            Label("我的", systemImage: "person")
        }
        .tag(1)
}
.tabViewStyle(.automatic)  // 或 .page(页面式)

13. Grid (iOS 16+) - 网格布局

Grid {
    GridRow {
        Text("姓名")
        Text("年龄")
        Text("城市")
    }
    .font(.headline)
    
    Divider()
        .gridCellUnsizedAxes(.horizontal)
    
    GridRow {
        Text("张三")
        Text("25")
        Text("北京")
    }
}

四、布局辅助视图

14. Spacer - 间距器

HStack {
    Text("左")
    Spacer()  // 将左右视图推向两端
    Text("右")
}

VStack {
    Text("顶部")
    Spacer(minLength: 20)  // 最小间距
    Text("底部")
}

15. Divider - 分割线

VStack {
    Text("上部分")
    Divider()  // 水平分割线
    Text("下部分")
}

16. Group - 分组容器

VStack {
    Group {
        if condition {
            Text("条件1")
        } else {
            Text("条件2")
        }
    }
    .padding()
    .background(Color.yellow)
}
  • 作用
    • 突破10个子视图限制
    • 统一应用修饰符
    • 条件逻辑分组

17. ViewBuilder - 视图构建器

@ViewBuilder
func createView(showDetail: Bool) -> some View {
    Text("基础")
    if showDetail {
        Text("详情")
        Image(systemName: "star")
    }
}

五、自定义布局容器

18. Layout 协议 (iOS 16+)

struct MyCustomLayout: Layout {
    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) -> CGSize {
        // 计算布局所需大小
        CGSize(width: proposal.width ?? 300, height: 200)
    }
    
    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) {
        // 放置子视图
        var point = bounds.origin
        for subview in subviews {
            subview.place(at: point, proposal: .unspecified)
            point.x += 100
        }
    }
}

19. AnyLayout (iOS 16+)

@State private var isVertical = true

var body: some View {
    let layout = isVertical ? AnyLayout(VStackLayout()) : AnyLayout(HStackLayout())
    
    layout {
        Text("视图1")
        Text("视图2")
    }
}

六、布局修饰符

20. frame - 尺寸约束

Text("Hello")
    .frame(
        maxWidth: .infinity,  // 最大宽度
        minHeight: 50,        // 最小高度
        alignment: .center    // 对齐方式
    )

21. padding - 内边距

Text("内容")
    .padding()                    // 所有方向
    .padding(.horizontal, 10)     // 水平方向
    .padding(.top, 20)           // 顶部
    .padding(EdgeInsets(top: 10, leading: 20, bottom: 10, trailing: 20))

22. position & offset - 位置调整

Text("绝对定位")
    .position(x: 100, y: 100)  // 相对于父视图
    
Text("相对偏移")
    .offset(x: 10, y: -5)      // 相对当前位置

七、布局优先级

23. 布局优先级

HStack {
    Text("短文本")
        .layoutPriority(1)  // 高优先级,先分配空间
    
    Text("这是一个非常长的文本,可能会被压缩")
        .layoutPriority(0)  // 低优先级
}
.frame(width: 200)

八、布局选择指南

容器 适用场景 性能特点
VStack/HStack 简单布局,子视图数量少 立即布局所有子视图
LazyVStack/LazyHStack 长列表,滚动视图 惰性加载,高性能
List 数据列表,需要交互 高度优化,支持选择、删除等
Grid/LazyVGrid 网格布局,瀑布流 灵活的多列布局
ZStack 重叠布局,层叠效果 适合覆盖、浮动元素
ScrollView 自定义滚动内容 需要手动管理性能

九、最佳实践

  1. 选择合适的容器:根据需求选择最合适的布局容器
  2. 避免过度嵌套:简化布局层级,提高性能
  3. 使用惰性容器:处理大量数据时使用Lazy容器
  4. 利用Spacer:灵活控制视图间距
  5. 组合使用:合理组合多个容器实现复杂布局
  6. 测试多尺寸:在不同设备尺寸和方向上测试布局

这些容器可以灵活组合使用,创造出各种复杂的用户界面。掌握这些布局容器的特性和适用场景,是成为SwiftUI布局专家的关键。

一次弹窗异常引发的思考:iOS present / push 底层机制全解析

这篇文章从一个真实线上问题讲起: 在弹窗VC 里点了一行cell,结果直接跳回了UITabBarController。 借着排查这个 Bug 的过程,我系统梳理了一遍 iOS 中与导航相关的底层机制:present/dismiss、push/pop、“获取顶层 VC(getTopVC)”、以及 UITableView 的选中/取消逻辑。


一、视图控制器层级:Navigation 栈 vs Modal 链

1. 两套完全独立的层级体系

Navigation 栈(push/pop)

  • 结构:UINavigationController.viewControllers = [VC0, VC1, VC2, ...]
  • 行为:
    • pushViewController::追加到数组尾部
    • popViewControllerAnimated::从数组尾部移除
  • 只影响 导航栈 中的顺序,不改变谁 present 了谁。

Modal 链(present/dismiss)

  • 结构:由 presentingViewController / presentedViewController 串联成一条链:
    • A.presentedViewController = B
    • B.presentedViewController = C
  • 行为:
    • presentViewController::在当前 VC 上方展示一个新 VC
    • dismissViewControllerAnimated::从某个 VC 开始,把它和它上面所有通过它 present 出来的 VC 一起收回

记忆方式:

  • push/pop 操作的是 “数组”(导航栈)
  • present/dismiss 操作的是 “链表”(模态链)

2. 组合层级的典型例子

A(Tab 内业务页) └─ present → B(弹窗或二级页,带导航) └─ push → C(B 的导航栈里再 push 出来的 VC)- 导航栈(以 B 的导航控制器为例):[B, C]

  • 模态链:A -(present)-> B

关键结论:

dismiss B ⇒ B 和 B 承载的那棵 VC 树一起消失 ⇒ 导航回到 A(B 的 presentingViewController)。
UIKit 不支持 “只 dismiss B 保留 C” 这种结构。


二、dismissViewControllerAnimated: 的真实含义

[vc dismissViewControllerAnimated:YES completion:nil];核心点:

  1. 这个调用作用在 “vc 所在的模态链” 上,而不是导航栈。
  2. 如果 vc 是被某个 VC 通过 presentViewController: 推出来的,那么:
    • 系统会找到它的 presentingViewController
    • 把从 vc 起到链尾的所有 VC 都 dismiss 掉
    • 显示回到 presentingViewController

1. 谁调用 vs 谁被 dismiss

很多人容易混淆这两种写法:

[self dismissViewControllerAnimated:YES completion:nil];

[[self getTopVC] dismissViewControllerAnimated:YES completion:nil];

只要这两种写法最终作用到的是同一个 VC,它们的行为完全一致。

  • 决定回到哪里的,是「被 dismiss 的那个 VC 的 presentingViewController」,而不是“谁来触发这次调用”。
  • 这也是为什么单纯把 self 改成 [self getTopVC]并不能改变 dismiss 之后的落点。

2. presentingViewController 的生命周期

[parentVC presentViewController:childVC animated:YES completion:nil];
  • 在这行代码执行完成时:
    • childVC.presentingViewController = parentVC 被永久确定
  • 后续不管从哪里、什么时候触发:
    • 只要 dismiss 的对象是 childVC,最终都会回到同一个 parentVC

三、“顶层 VC” 工具(如 getTopVC)的时序问题

很多项目中都会有类似如下工具方法:

@implementation UIViewController(Additions)

- (UIViewController*)getTopVC {
    if (self.presentedViewController) {
        return [self.presentedViewController getTopVC];
    }
    if ([self isKindOfClass:UITabBarController.class]) {
        return [[(UITabBarController*)self selectedViewController] getTopVC];
    }
    else if ([self isKindOfClass:UINavigationController.class]) {
        return [[(UINavigationController*)self visibleViewController] getTopVC];
    }
    return self;
}

@end

@implementation UIApplication (Additions)

+ (UIViewController *)getCurrentTopVC{
    UIViewController *currentVC = [UIApplication sharedApplication].delegate.window.rootViewController;
    return [currentVC getTopVC];
}
@end

关键:这类函数对「调用时机」极度敏感。

情况 1:在“弹窗 VC 还在屏幕上”时调用

比如某个present出来的弹窗 VC 还没有被 dismiss,这时调用 getTopVC(),返回的就是这个弹窗 VC。

情况 2:在“弹窗 VC 已经被 dismiss 掉”之后调用

当 弹窗 VC 已经执行过 dismissViewControllerAnimated:,不再显示在屏幕上,这时再调用 getTopVC(),返回的就是它下面那一层控制器(例如列表页、TabBar 下当前选中的子控制器),而不再是 弹窗 VC 本身。

情况3: 一个典型的 Bug 时序

  1. 子类在 cell 点击时,调了父类的 didSelectRowAtIndexPath:
  2. 父类内部逻辑(伪代码):
 [self dismissViewControllerAnimated:YES completion:^{
     if (self.didSelectedIndex) {
         self.didSelectedIndex(indexPath.row);  // 触发外层 block
     }
 }];

也就是说:先 dismiss 自己,再回调外层 block

  1. 外层 block 中再执行:
 [[UIApplication getCurrentTopVC] dismissViewControllerAnimated:YES completion:nil];

由于这时 弹框VC 已经被 dismiss 掉,getCurrentTopVC() 拿到的是 下层 VC(例如一个筛选页或 TabBar) 于是第二次 dismiss 把下层页面也关掉了 4. 用户看到的效果就是:

点击弹窗里的一个 cell ⇒ 弹窗消失 ⇒ 当前页面也被关闭 ⇒ 直接回到了 TabBar

根本原因:
第二次调用 getTopVC()时机太晚,此时“顶层 VC”已经不是弹窗,而是它下面的页面。


四、UITableView 的选中/取消逻辑

1. 系统接口的作用

  • selectRowAtIndexPath:animated:scrollPosition: 会:

    • 更新 tableView 内部的选中状态;
    • 调用 cell 的 setSelected:YES
    • 触发 tableView:didSelectRowAtIndexPath: 回调。
  • deselectRowAtIndexPath:animated: 会:

    • 清除选中状态;
    • 调用 cell 的 setSelected:NO
    • 触发 tableView:didDeselectRowAtIndexPath: 回调。

也就是说,单靠 deselectRowAtIndexPath:,就已经隐含执行了很多事情,不必再额外手工写 cell.selected = NO

2. 单选列表中的推荐顺序

UITableView 在单选模式下,用户点一个新 row 时,系统内部的默认顺序是:先调用 didDeselectRowAtIndexPath:(旧 row)→ 再调用 didSelectRowAtIndexPath:(新 row)。 所以在自定义 cell 中的选中/取消逻辑时, 推荐顺序调用这2个方法

例如: 你维护了一个 selectedIndex,在代码中手动切换选中行时,可以这样写:

NSIndexPath *oldIndexPath = [NSIndexPath indexPathForRow:self.selectedIndex inSection:0];
NSIndexPath *newIndexPath = indexPath;

// 1. 先取消旧的
[tableView deselectRowAtIndexPath:oldIndexPath animated:YES];

// 2. 再选中新的
[tableView selectRowAtIndexPath:newIndexPath
                       animated:YES
                 scrollPosition:UITableViewScrollPositionNone];这样能确保:
  • 旧 cell 的 setSelected:NO / didDeselect 逻辑先执行;
  • 新 cell 的 setSelected:YES / didSelect 后执行;
  • 对自定义 cell(在 setSelected: 里更换图标、颜色等)尤为友好;
  • 不会出现两个 cell 同时高亮的瞬间状态。

3. 何时可以不再调用父类 tableView:didDeselectRowAtIndexPath:

如果父类的 tableView:didDeselectRowAtIndexPath: 实现只是:

  • (void)tableView:(UITableView *)tableView didDeselectRowAtIndexPath:(NSIndexPath *)indexPath { UITableViewCell *cell = [tableView cellForRowAtIndexPath:indexPath]; [cell setSelected:NO]; }, 而你在子类里已经调用了 deselectRowAtIndexPath:animated:,那么:

  • 系统内部已经帮你执行了 setSelected:NO

  • 再手动调用父类 didDeselectRowAtIndexPath: 属于重复操作,可以安全省略。


五、“到 C 后不能回 B”:通过修改导航栈实现

用户需求

当前导航栈:A -> B -> C

期望: 在C上点击返回时直接回到 A,不能再回到 B。

代码实现

-- push 到新 VC,并从栈中移除当前 VC
-- 修改 `viewControllers` 数组, 重置导航栈
- (void)deleteCurrentVCAndPush:(UIViewController *)viewController animated:(BOOL)animated {
    UIViewController* top = self.topViewController;
    [self pushViewController:viewController animated:animated];
    NSMutableArray* viewControllers = [self.viewControllers mutableCopy];
    [viewControllers removeObject:top];
    
    [self setViewControllers:viewControllers animated:NO];
}

与 dismiss 的区别

  • 修改导航栈:
    • 仅操作 navigationController.viewControllers 数组;
    • 不改变 modal 链,presentingViewController 关系保持不变;
  • dismiss 某个 VC:
    • 只看 modal 链;
    • 会回到 presentingViewController
    • 无法仅移除 B 而让 C 留在界面上。

六、整体总结

  1. 理解 Navigation 栈与 Modal 链是所有导航问题的基础

    • push/pop 只改数组
    • present/dismiss 只改链表
  2. dismissViewControllerAnimated: 的返回点由 presentingViewController 决定

    • 谁调用不重要,谁被 dismiss 才重要。
  3. “获取顶层 VC” 的工具对调用时机非常敏感

    • 在 VC 被 dismiss 前后调用,返回的完全是不同的对象;
    • 在错误的时机用它再发起一次 dismiss,往往会“多退一层”。
  4. 手动控制 UITableView 的选中状态时,优先使用 select/deselect 接口,并保持“先取消旧选中,再选中新行”的顺序

  5. “到 C 后不能回 B”这类需求,本质是对导航栈的重写,而非 dismiss 某个 VC

    • 正确做法是修改 viewControllers 数组,或使用封装好的 “deleteCurrentVCAndPush” 类方法。

掌握这些底层规则,遇到类似“弹窗关闭顺序错乱”、“页面一点击就跳回根控制器”、“导航上跳过某一层”等问题时,就能更快定位根因,设计出行为可控、易维护的解决方案。

iOS逆向-哔哩哔哩增加3倍速播放(2)-[横屏视频-半屏播放]增加3倍速播放

前言

作为一名 哔哩哔哩的重度用户,我一直期待官方推出 3 倍速播放 功能。然而等了许久,这个功能始终没有上线 😮‍💨。

修改前效果: Screenshot 2025-12-11 at 07.26.05.png

刚好我自己熟悉 iOS 逆向工程,于是决定 亲自动手,为 B 站加入 3 倍速播放 😆。

修改后效果: Screenshot 2025-12-11 at 07.22.57.png

由于整个过程涉及 多处逻辑修改与多个模块的反汇编分析,为了让内容更加清晰易读,我将会分成多篇文章,逐步拆解 如何为 B 站增加 3 倍速播放能力

场景

[横屏视频-半屏播放]的播放页面

CE1C32DB-8B78-4543-844C-5283FA858E86.png

开发环境

  • 哔哩哔哩版本:8.41.0

  • MonkeyDev

  • IDA Professional 9.0

  • 安装IDA插件:patching

  • Lookin

目标

[横屏视频-半屏播放]增加三倍速播放

分析

  • Lookin可以知道,播放速度组件叫做VKSettingView.SelectContent

1D8085C7-9797-435A-A5E2-3D748FE9B097.png

  • Mach-O文件导出的VKSettingView.SelectContentswift文件可以知道,它的model叫做VKSettingView.SelectModel
class VKSettingView.SelectContent: VKSettingView.TitleBaseContent {
  /* fields */
    var model: VKSettingView.SelectModel ?
    var lazy selecter: VKSettingView.VKSelectControl ?
}
  • VKSettingView.SelectModel有个items属性,有可能是播放速度数组。我们从IDA依次查看方法的实现,找到itemssetter方法叫做sub_10D8ACB88
import Foundation

class VKSettingView.SelectModel: VKSettingView.BaseModel {
  /* fields */
    var icon: String
    var items: [String]
    var reports: [String]
    var selectedIndex: Int
    var dynamicSelectedString: String?
    var enableRepeatSelect: Swift.Bool
    var selectChangeCallback: ((_:_:))?
    var preferScrollPosition: VKSettingView.VKSelectControlScrollPosition
  /* methods */
    func sub_10d8aca08 // getter (instance)
    func sub_10d8acac4 // setter (instance)
    func sub_10d8acb20 // modify (instance)
    func sub_10d8acb70 // getter (instance)
    func sub_10d8acb88 // setter (instance)
    func sub_10d8acb94 // modify (instance)
    func sub_10d8acc48 // getter (instance)
    func sub_10d8acd10 // setter (instance)
    func sub_10d8acd68 // modify (instance)
    func sub_10d8acf6c // getter (instance)
    func sub_10d8acff8 // setter (instance)
    func sub_10d8ad040 // modify (instance)
    func sub_10d8ad138 // getter (instance)
    func sub_10d8ad234 // setter (instance)
    func sub_10d8ad2a0 // modify (instance)
    func sub_10d8ad328 // getter (instance)
    func sub_10d8ad3b4 // setter (instance)
    func sub_10d8ad3fc // modify (instance)
}

5C5C3435-8A3B-47BB-8689-E56D31E2617E.png

  • 我们在Xcode添加符号断点sub_10D8ACB88,看到底谁设置了items的值

E7D376B3-3226-4C6E-AFA4-F9F94058CE32.png

  • sub_10D8ACB88断点触发,我们打印参数的值,证明items确实是播放速度数组
(lldb) p (id)$x0
(_TtGCs23_ContiguousArrayStorageSS_$ *) 0x00000001179c8370
(lldb) expr -l Swift -- unsafeBitCast(0x00000001179c8370, to: Array<String>.self)
([String]) $R4 = 6 values {
  [0] = "0.5"
  [1] = "0.75"
  [2] = "1.0"
  [3] = "1.25"
  [4] = "1.5"
  [5] = "2.0"
}
  • 我们打印方法的调用堆栈,发现是sub_10A993E14修改了items的值
(lldb) bt
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 3.1
  * frame #0: 0x000000010de78b88 bili-universal`sub_10D8ACB88
    frame #1: 0x000000010af5fea0 bili-universal`sub_10A993E14 + 140
    frame #2: 0x000000010af5f15c bili-universal`sub_10A992320 + 3644
    frame #3: 0x000000010af5db20 bili-universal`sub_10A9916B4 + 1132
    frame #4: 0x000000010af6714c bili-universal`sub_10A99B130 + 28
    frame #5: 0x000000010af6859c bili-universal`sub_10A99C1A0 + 1020
    frame #6: 0x000000010af67128 bili-universal`sub_10A99B118 + 16
...
  • 我们从IDA看下sub_10A993E14的伪代码实现
_QWORD *__fastcall sub_10A993E14(void *a1, id a2)
{
...

  v3 = a2;
  if ( a2 && (v4 = v2, v6 = type metadata accessor for SelectModel(0LL), (v7 = swift_dynamicCastClass(v3, v6)) != 0) )
  {
    v9 = (_QWORD *)v7;
    v10 = sub_107C8B79C(&unk_116BB42E8, v8);
    inited = swift_initStaticObject(v10, &unk_116E60370);
    v12 = *(void (__fastcall **)(__int64))((swift_isaMask & *v9) + 0x1C0LL);
    v13 = objc_retain(v3);
...
  • 我们直接搜索sub_10A993E14的伪代码,看是否有直接调用sub_10D8ACB88,很遗憾并没有
  • 我们添加sub_10A993E14符号断点,断点触发后打印方法的参数,发现x1的值是_TtC13VKSettingView11SelectModel,也就是VKSettingView.SelectModel
(lldb) p (id)$x0
(BAPIPlayersharedSettingItem *) 0x0000000282c0f880
(lldb) p (id)$x1
(_TtC13VKSettingView11SelectModel *) 0x0000000283b51790
  • 我们打印x1(VKSettingView.SelectModel)(0x0000000283b51790)的items的值,发现是个空数组
(lldb) p (id)$x1
(_TtC13VKSettingView11SelectModel *) 0x0000000283b51790
(lldb) p [(_TtC13VKSettingView11SelectModel *) 0x0000000283b51790 items]
(_TtCs19__EmptyArrayStorage *) 0x00000001dd92e978
(lldb) expr -l Swift -- unsafeBitCast(0x00000001dd92e978, to: Array<String>.self)
([String]) $R2 = 0 values {}
  • 我们在sub_10A993E14方法返回之前添加一个断点,看下x1(VKSettingView.SelectModel)(0x0000000283b51790)的items的值

16093E5E-4BD2-4281-9945-297047044F27.png

(lldb) register read 
General Purpose Registers:
        x0 = 0x0000000283b51790
        x1 = 0x00000002819eb700
        x2 = 0x0000000000000003
...
       x23 = 0x0000000283b51790
       x24 = 0x0000000283b51790
       x25 = 0x0000000116e17f28  (void *)0x00000001173e6b88: OBJC_METACLASS_$__TtC16BBUGCVideoDetail13VDUGCMoreBloc
       x26 = 0x00000001142906d8  bili-universal`type_metadata_for_ToolCell + 784
       x27 = 0x000000010a552534  bili-universal`sub_109F86534
       x28 = 0x0000000116718000  "badge_control"
        fp = 0x000000016f832610
        lr = 0x000000010af5f15c  bili-universal`sub_10A992320 + 3644
        sp = 0x000000016f832510
        pc = 0x000000010af5ffe4  bili-universal`sub_10A993E14 + 464
      cpsr = 0x60000000
  • 因为x0的值是0x0000000283b51790,所以打印x0的值,看到x0(VKSettingView.SelectModel)(0x0000000283b51790)的items有值了,就是播放速度数组,这也证明sub_10A993E14修改了VKSettingView.SelectModelitems的值。x0通常拿来存放函数的返回值。
(lldb) p (id)$x0
(_TtC13VKSettingView11SelectModel *) 0x0000000283b51790
(lldb) p [(_TtC13VKSettingView11SelectModel *) 0x0000000283b51790 items]
(_TtCs22__SwiftDeferredNSArray *) 0x0000000280743120 6 values
(lldb) po 0x0000000280743120
<Swift.__SwiftDeferredNSArray 0x280743120>(
0.5,
0.75,
1.0,
1.25,
1.5,
2.0
)
  • 我们将sub_10A993E14的伪代码,参数a1的类型是BAPIPlayersharedSettingItema2的类型是VKSettingView.SelectModel一起给chatgpt分析,chatgpt叫我们查看 swift_initStaticObject 的参数 &unk_116E60370的值是什么。
    • 如果chatgpt的分析结果没用,我们就自己打断点,看是哪些汇编代码更改了items的值,再看汇编代码对应的伪代码是怎样的。
检查 inited = swift_initStaticObject(...) 对象
inited 很可能是 SelectModel 或其内部配置对象(例如某个静态配置结构体或 Swift 字典/数组)被初始化。你可以在反汇编中查看 swift_initStaticObject 的参数 &unk_116E60370 看看该静态对象是什么,它可能携带 items 的初始数据。若你在数据段或只读段中找到与 “items” 相关的字符串数组、常量字符串列表、NSStringPointer 等,那可能就是 items 的来源。
  • 查看&unk_116E60370的值,发现是在数据段(__data)中

AD08C629-FC16-405A-B8EC-D4F866C62775.png

  • 查看&unk_116E60370的值的16进制视图,发现它旁边的地址存放着播放速度,所以&unk_116E60370保存着播放速度数组

0DF1A4B8-7DCF-4F83-A4DB-6C484DF53598.png

  • 我们知道数据段(__data)存放着全局变量,所以播放速度数组应该是放在一个全局变量里面,类似:
var playbackRates = ["0.5", "0.75", "1.0", "1.25", "1.5", "2.0"]

说明

比如0000000116E789B0,保存的值是0.75

0000000116E789B0  30 2E 37 35 00 00 00 00  00 00 00 00 00 00 00 E4  0.75............

各个字节的解析如下,特别是最后一个字节E4,代表要读取4个字节的数据,如果是E3代表要读取3个字节的数据

30 : 0
2E : .
37 : 7
35 : 5
E4 : 读取四个字节的数据

越狱解决方案

  • 修改下面地址存储的值
0000000116E60390  30 2E 35 00 00 00 00 00  00 00 00 00 00 00 00 E3  0.5.............
0000000116E603A0  30 2E 37 35 00 00 00 00  00 00 00 00 00 00 00 E4  0.75............
0000000116E603B0  31 2E 30 00 00 00 00 00  00 00 00 00 00 00 00 E3  1.0.............
0000000116E603C0  31 2E 32 35 00 00 00 00  00 00 00 00 00 00 00 E4  1.25............
0000000116E603D0  31 2E 35 00 00 00 00 00  00 00 00 00 00 00 00 E3  1.5.............
0000000116E603E0  32 2E 30 00 00 00 00 00  00 00 00 00 00 00 00 E3  2.0.............
  • 具体代码
/// 将速度写入到内存地址
/// - Parameters:
///   - dest_addr: 目标内存地址
///   - str: 速度字符串,比如"1.0"
static int write_rate_string_to_address(uintptr_t dest_addr, NSString *str) {
    if (str == nil) {
        return -1;
    }

    // UTF8 字符串
    const char *utf8Str = [str UTF8String];
    size_t strLength = strlen(utf8Str);   // 字符数(不含 \0)

    if (strLength > (NJ_RATE_BLOCK_SIZE - 1)) {
        // 只能容纳前15字节 + 最后一字节用于 E0+strLength
        strLength = NJ_RATE_BLOCK_SIZE - 1;
    }

    uint8_t block[NJ_RATE_BLOCK_SIZE];
    memset(block, 0, NJ_RATE_BLOCK_SIZE);

    // 前 strLength 字节写入字符串
    memcpy(block, utf8Str, strLength);

    // 最后一个字节写入:E0 + 长度
    block[NJ_RATE_BLOCK_SIZE - 1] = 0xE0 + (uint8_t)strLength;

    // 将 block 写到目标地址
    memcpy((void *)dest_addr, block, NJ_RATE_BLOCK_SIZE);

    return 0;
}


/// 将速度写入到内存地址
/// - Parameter baseAddress: 起始内存地址
static void write_rate_to_address(uintptr_t baseAddress) {
    NSArray<NSString *> *playbackRates = @[@"0.5", @"1.0", @"1.25", @"1.5", @"2.0", @"3.0"];
    NSInteger count = playbackRates.count;
    for (NSInteger i = 0; i < count; i++) {
        uintptr_t currentAddress = baseAddress + i * NJ_RATE_BLOCK_SIZE;
        write_rate_string_to_address(currentAddress, playbackRates[i]);
    }
}

// [横屏视频-半屏播放]的播放速度
static void changePlaybackRates_LandscapeVideo_HalfScreenPlayback() {
    /*
     0000000116E60390  30 2E 35 00 00 00 00 00  00 00 00 00 00 00 00 E3  0.5.............
     0000000116E603A0  30 2E 37 35 00 00 00 00  00 00 00 00 00 00 00 E4  0.75............
     0000000116E603B0  31 2E 30 00 00 00 00 00  00 00 00 00 00 00 00 E3  1.0.............
     0000000116E603C0  31 2E 32 35 00 00 00 00  00 00 00 00 00 00 00 E4  1.25............
     0000000116E603D0  31 2E 35 00 00 00 00 00  00 00 00 00 00 00 00 E3  1.5.............
     0000000116E603E0  32 2E 30 00 00 00 00 00  00 00 00 00 00 00 00 E3  2.0.............
     */
    uintptr_t baseAddress = g_slide + 0x116E60390;
    write_rate_to_address(baseAddress);
}

非越狱解决方案

修改Mach-O文件的汇编指令

目标

  • 修改下面地址存储的值
0000000116E60390  30 2E 35 00 00 00 00 00  00 00 00 00 00 00 00 E3  0.5.............
0000000116E603A0  30 2E 37 35 00 00 00 00  00 00 00 00 00 00 00 E4  0.75............
0000000116E603B0  31 2E 30 00 00 00 00 00  00 00 00 00 00 00 00 E3  1.0.............
0000000116E603C0  31 2E 32 35 00 00 00 00  00 00 00 00 00 00 00 E4  1.25............
0000000116E603D0  31 2E 35 00 00 00 00 00  00 00 00 00 00 00 00 E3  1.5.............
0000000116E603E0  32 2E 30 00 00 00 00 00  00 00 00 00 00 00 00 E3  2.0.............

示例

比如修改0000000116E603A0

0000000116E603A0  30 2E 37 35 00 00 00 00  00 00 00 00 00 00 00 E4  0.75............
  • 鼠标点击0000000116E603A0
  • IDA->Edit->Patch program->Change byte

9FFF3DBB-69F3-49EB-9A32-8CA94207D159.png

  • 显示Patch Bytes弹框

D3949C2A-9560-4D76-A710-ADAAE4E75A81.png

  • Origin value
    • 30 2E 37 35 00 00 00 00 00 00 00 00 00 00 00 E4
  • 修改 Values 为:
    • 31 2E 30 00 00 00 00 00 00 00 00 00 00 00 00 E3
  • 点击OK,真正修改

修改结果

  • 当前的值:
0.530 2E 35 00 00 00 00 00  00 00 00 00 00 00 00 E3
0.7530 2E 37 35 00 00 00 00  00 00 00 00 00 00 00 E4
1.031 2E 30 00 00 00 00 00  00 00 00 00 00 00 00 E3
1.2531 2E 32 35 00 00 00 00  00 00 00 00 00 00 00 E4
1.531 2E 35 00 00 00 00 00  00 00 00 00 00 00 00 E3
2.032 2E 30 00 00 00 00 00  00 00 00 00 00 00 00 E3
  • 新的播放速度对应的值:
0.530 2E 35 00 00 00 00 00  00 00 00 00 00 00 00 E3
1.031 2E 30 00 00 00 00 00  00 00 00 00 00 00 00 E3
1.2531 2E 32 35 00 00 00 00  00 00 00 00 00 00 00 E4
1.531 2E 35 00 00 00 00 00  00 00 00 00 00 00 00 E3
2.032 2E 30 00 00 00 00 00  00 00 00 00 00 00 00 E3
3.033 2E 30 00 00 00 00 00  00 00 00 00 00 00 00 E3
  • 全部修改完后
0000000116E60390  30 2E 35 00 00 00 00 00  00 00 00 00 00 00 00 E3  0.5.............
0000000116E603A0  31 2E 30 00 00 00 00 00  00 00 00 00 00 00 00 E3  1.0.............
0000000116E603B0  31 2E 32 35 00 00 00 00  00 00 00 00 00 00 00 E4  1.25............
0000000116E603C0  31 2E 35 00 00 00 00 00  00 00 00 00 00 00 00 E3  1.5.............
0000000116E603D0  32 2E 30 00 00 00 00 00  00 00 00 00 00 00 00 E3  2.0.............
0000000116E603E0  33 2E 30 00 00 00 00 00  00 00 00 00 00 00 00 E3  3.0.............

保存

保存到Mach-O文件

  • IDA->Edit->Patch program->Apply patches to input file->OK

9D1A3899-9871-4DF3-BC43-6D3109085192.png

29737F6A-DF9E-48AB-A46A-77363CA8546B.png

  • 保存后,底部会显示log

    Patch successful: /Users/touchworld/Documents/iOSDisassembler/hook/bilibili/IDA_max_0/bili-universal
    

    F1E26F23-6F74-4DEC-B1CD-1256372F5FBE.png

效果

73D09925-1B81-42A9-8392-8EFB88B9884D.png

代码

BiliBiliMApp-无广告版哔哩哔哩

记住这张时间线图,你再也不会乱用 useEffect / useLayoutEffect

useEffect 和 useLayoutEffect 的区别:别背定义,按“什么时候上屏”来选

以前一直写vue,现在写react了,写react代码的时候有时候会碰到一个选择题:

  • 这个副作用用 useEffect 还是 useLayoutEffect
  • 为什么我用 useEffect 量 DOM 会闪一下?
  • Next.js 里 useLayoutEffect 为什么会给我一个 warning?

这俩 Hook 的差别,说穿了就一句:它们跑在“上屏(paint)”的前后


一句话结论(先拿走)

  • 默认用 useEffect:不会挡住浏览器绘制。
  • 只有在“必须读布局/写布局且不能闪”的时候用 useLayoutEffect:它会在浏览器 paint 之前同步执行。

如果你脑子里只留两句话,就留这两句。


它们到底差在哪:在浏览器 paint 的前后

把 React DOM 的一次更新粗暴拆成四步,你就不会混了:

flowchart LR
  A[render 计算 JSX] --> B[commit 写入 DOM]
  B --> C[useLayoutEffect 同步执行]
  C --> D[浏览器 paint 上屏]
  D --> E[useEffect 执行]

  classDef info fill:#cce5ff,stroke:#0d6efd,color:#004085
  classDef warning fill:#fff3cd,stroke:#ffc107,color:#856404

  class C warning
  class E info
  • useLayoutEffectDOM 已经变了,但还没 paint。它会阻塞本次 paint。
  • useEffect页面已经 paint 了。它不会阻塞上屏(但也意味着你在里面改布局可能会“先错后改”,肉眼看到就是闪)。

注意我在说的是“commit 后”的那个时间点,不是 render 阶段。


一个很真实的例子:测量 DOM 决定位置(useEffect 会闪)

比如你做一个 Tooltip:初始不知道自己宽高,得先 render 出来,然后用 getBoundingClientRect() 量一下,再把位置修正。

如果你用 useEffect

  • 第一次 paint:Tooltip 先用默认位置上屏
  • effect 里量完 -> setState
  • 第二次 paint:位置修正

用户看到的就是“闪一下”。如果你用 useLayoutEffect,修正发生在 paint 之前,第一帧就是对的。

下面这段代码可以直接在 React DOM 里跑(为了不违反 Hooks 规则,我写成两个组件,用 checkbox 切换时会 remount):

import React, { useEffect, useLayoutEffect, useRef, useState } from "react";

type TooltipPosition = {
  anchorRef: React.RefObject<HTMLButtonElement | null>;
  tipRef: React.RefObject<HTMLDivElement | null>;
  left: number;
};

function calcLeft(anchor: HTMLButtonElement, tip: HTMLDivElement) {
  const a = anchor.getBoundingClientRect();
  const t = tip.getBoundingClientRect();
  return Math.round(a.left + a.width / 2 - t.width / 2);
}

function useTooltipPositionWithEffect(): TooltipPosition {
  const anchorRef = useRef<HTMLButtonElement | null>(null);
  const tipRef = useRef<HTMLDivElement | null>(null);
  const [left, setLeft] = useState(0);

  useEffect(() => {
    const anchor = anchorRef.current;
    const tip = tipRef.current;
    if (!anchor || !tip) return;
    setLeft(calcLeft(anchor, tip));
  }, []);

  return { anchorRef, tipRef, left };
}

function useTooltipPositionWithLayoutEffect(): TooltipPosition {
  const anchorRef = useRef<HTMLButtonElement | null>(null);
  const tipRef = useRef<HTMLDivElement | null>(null);
  const [left, setLeft] = useState(0);

  useLayoutEffect(() => {
    const anchor = anchorRef.current;
    const tip = tipRef.current;
    if (!anchor || !tip) return;
    setLeft(calcLeft(anchor, tip));
  }, []);

  return { anchorRef, tipRef, left };
}

function TooltipFrame({ pos }: { pos: TooltipPosition }) {
  return (
    <>
      <button ref={pos.anchorRef} style={{ marginLeft: 120 }}>
        Hover me
      </button>

      <div
        ref={pos.tipRef}
        style={{
          position: "fixed",
          top: 80,
          left: pos.left,
          padding: "8px 10px",
          borderRadius: 8,
          background: "#111827",
          color: "#fff",
          fontSize: 12,
          whiteSpace: "nowrap",
        }}
      >
        I am a tooltip
      </div>
    </>
  );
}

function DemoUseEffect() {
  return <TooltipFrame pos={useTooltipPositionWithEffect()} />;
}

function DemoUseLayoutEffect() {
  return <TooltipFrame pos={useTooltipPositionWithLayoutEffect()} />;
}

export function Demo() {
  const [layout, setLayout] = useState(false);

  return (
    <div style={{ padding: 40 }}>
      <label style={{ display: "block", marginBottom: 12 }}>
        <input
          type="checkbox"
          checked={layout}
          onChange={(e) => setLayout(e.target.checked)}
        />{" "}
        用 useLayoutEffect(勾上后更不容易闪)
      </label>

      {layout ? <DemoUseLayoutEffect /> : <DemoUseEffect />}
    </div>
  );
}

真实项目里你可能还会处理 resize、内容变化(ResizeObserver)、字体加载导致的宽度变化等;但对理解这两个 Hook 的差别,上面这个例子够用了。


怎么选:我自己用的“决策口诀”

1)只要不读/写布局,就用 useEffect

典型场景:

  • 请求数据、上报埋点
  • 订阅/取消订阅(WebSocket、EventEmitter)
  • document.titlelocalStorage 同步
  • 给 window/document 绑事件

这些东西不需要卡在 paint 之前完成,useEffect 更合适。

2)你要读布局(layout read)并且会影响第一帧渲染,就用 useLayoutEffect

典型场景:

  • getBoundingClientRect() / offsetWidth / scrollHeight 这种
  • 计算初始滚动位置、同步滚动
  • 需要避免视觉抖动的“测量 -> setState”
  • focus / selection(输入框聚焦、光标定位)对首帧体验敏感

一句话:“不想让用户看到中间态”

3)别在 useLayoutEffect 里干重活

因为它会阻塞 paint:

  • 你在里面做重计算,页面就掉帧
  • 你在里面频繁 setState,可能放大卡顿

如果你只是“想早点跑一下”,但并不依赖布局,别用它。


Next.js / SSR 里那个 warning 怎么回事

在服务端渲染(SSR)时:

  • useEffect 本来就不会执行(它只在浏览器跑)
  • useLayoutEffect 也不会执行,但 React 会提示你:它在服务端没意义,可能导致你写出“依赖布局但 SSR 不存在布局”的代码

如果你写的是“浏览器才有意义的 layout effect”,又不想看到 warning,常见做法是包一层:

import { useEffect, useLayoutEffect } from "react";

export const useIsomorphicLayoutEffect =
  typeof window !== "undefined" ? useLayoutEffect : useEffect;

然后把需要 layout 的地方用 useIsomorphicLayoutEffect


容易踩的坑(顺手说两句)

  • Strict Mode 下 effect 会在开发环境额外执行一次useEffectuseLayoutEffect 都一样,别拿这个现象判断线上行为。
  • “我在 useEffect 里 setState 为什么会闪?”:因为你改的是布局相关内容,第一帧已经 paint 了。
  • 不要把数据请求塞进 useLayoutEffect:它既不需要 paint 前完成,还可能拖慢上屏。

简单总结一下

  • useEffect:大多数副作用的默认选择。
  • useLayoutEffect:只在“必须卡在 paint 前解决”的那一小撮场景里用。

真要说区别,其实就是一句:你愿不愿意为了“第一帧正确”去挡住 paint


如果你觉得这篇文章有帮助,欢迎关注我的 GitHub,下面是我的一些开源项目:

Claude Code Skills(按需加载,意图自动识别,不浪费 token,介绍文章):

全栈项目(适合学习现代技术栈):

  • prompt-vault - Prompt 管理器,Next.js 15 + React 19 + tRPC 11 + Supabase 全栈示例,clone 下来配个免费 Supabase 就能跑
  • chat_edit - 双模式 AI 应用(聊天+富文本编辑),Vue 3.5 + TypeScript + Vite 5 + Quill 2.0 + IndexedDB

热门中概股美股盘前普涨,网易涨超3%

36氪获悉,热门中概股美股盘前普涨,截至发稿,网易涨超3%,百度、理想汽车涨超1%,小鹏汽车涨0.83%,哔哩哔哩涨0.52%,阿里巴巴涨0.47%,京东涨0.27%,拼多多涨0.15%。

天顺风能:拟定增募资不超过19.5亿元,用于长风新能源装备制造基地扩建等项目

36氪获悉,天顺风能公告,公司拟向特定对象发行股票募集资金总额不超过19.5亿元,将用于长风新能源装备制造基地扩建项目、天顺(射阳)风电海工智造项目(二期)、天顺(阳江)重型风电海工装备智能制造项目(一期)、阳江港吉树作业区#J8泊位码头工程项目、特种运输船舶购置项目及补充流动资金。

美国本周宣布推出“金卡”签证 专家直言无法带来预期经济效益

美国总统特朗普本周宣布推出“金卡”签证,外国人士只需花费100万美元即可获得美国的永久居留权和快速入籍途径,该资格符合现有的EB-1或EB-2移民签证类别。国际货币基金组织的一项研究发现,类似的投资签证计划可以增加政府收入,但通常仅适用于签证收费在政府资金中占很大比例的小型岛屿国家。美国这项看似“有利可图”的签证政策却并不被一些专家认可,除了法律效力的疑问外,他们还指出这项政策并不能带来特朗普政府预期的经济效益。(财联社)

赛力斯:部分募投项目调整内部投资结构及延期

36氪获悉,赛力斯公告,公司“工厂智能化升级与电驱产线建设项目”已于2025年6月达到预定可使用状态并投入使用,拟将节余募集资金19462万元(最终金额以项目实际最终支付为准)调整至“电动化车型开发及产品平台技术升级项目”使用。同时,公司拟调整2022年非公开发行“电动化车型开发及产品平台技术升级项目”“工厂智能化升级与电驱产线建设项目”“用户中心建设项目”的内部投资结构,拟将“用户中心建设项目”“电动化车型开发及产品平台技术升级项目”达到预定可使用状态的日期延期至2027年12月。

小鹏汽车:坚决杜绝任何形式的价格欺诈与不正当竞争行为

36氪获悉,小鹏汽车发布关于支持《汽车行业价格行为合规指南》的声明。小鹏汽车表示,全面审视并持续优化自身的价格管理与合规体系,确保从产品定价到销售服务的每一个环节都更加透明、规范。承诺将严格遵守明码标价原则,保障消费者的知情权与选择权,坚决杜绝任何形式的价格欺诈与不正当竞争行为。

比亚迪:积极响应《汽车行业价格行为合规指南(征求意见稿)》 推动行业高质量发展

36氪获悉,国家市场监督管理总局发布《汽车行业价格行为合规指南(征求意见稿)》公开征求意见。比亚迪将以《指南》为指引,持续优化自身的价格管理和合规体系建设。我们承诺将严格落实规范价格竞争行为的要求,保障消费者的利益,坚决杜绝任何形式的价格欺诈和不正当竞争行为。

金融监管总局:深入整治“内卷式”竞争,大力规范市场秩序

36氪获悉,12月12日,金融监管总局党委召开会议,传达学习中央经济工作会议精神,结合全国金融系统工作会议部署要求,研究贯彻落实措施。党委书记、局长李云泽主持会议。会议要求,着力提升强监管严监管质效。加快健全金融法治,精准规范高效执法,不断提高监管能力。引导银行保险机构树牢正确的经营观、业绩观、风险观,降本增效、苦练内功,努力实现差异化、特色化发展。深入整治“内卷式”竞争,大力规范市场秩序。高压严打非法金融活动,坚决守好人民群众“钱袋子”。

金融监管总局:支持稳定房地产市场,进一步发挥城市房地产融资协调机制作用

36氪获悉,12月12日,金融监管总局党委召开会议,传达学习中央经济工作会议精神,结合全国金融系统工作会议部署要求,研究贯彻落实措施。党委书记、局长李云泽主持会议。会议要求,有力有序防范化解重点领域风险。坚定履行风险防控首位主责,着力化解存量风险,坚决遏制增量风险,严守不“爆雷”底线。巩固拓展改革化险成果,深入推进中小金融机构减量提质。支持稳定房地产市场,进一步发挥城市房地产融资协调机制作用,助力构建房地产发展新模式。指导银行保险机构按照市场化法治化原则,积极化解地方政府融资平台金融债务风险。

世界黄金协会:11月全球实物黄金ETF流入达52亿美元

36氪获悉,世界黄金协会数据显示,11月全球实物黄金ETF流入达52亿美元,已连续六个月实现流入。尽管11月流入规模较前几个月有所放缓,但仍显著高于2024年2.92亿美元的月均流入水平。亚洲地区11月流入约32亿美元,中国市场再次领跑,流入达22亿美元。

奥特曼抱紧迪士尼大腿,OpenAI能避免被踢下牌桌吗?

本文来自微信公众号:超聚焦foci,作者:肖恩,题图来自:AI生成


米老鼠和奥特曼这下真要碰面了。


据The Information报道,12月11日,迪士尼宣布对OpenAI进行10亿美元的股权投资,并与之签署了一项为期三年的战略授权合作协议。


该协议内容还包括:OpenAI旗下的AI视频生成平台Sora将获得授权,可根据用户输入的提示词生成以迪士尼旗下IP为主题的短视频;迪士尼可利用OpenAI的API开发新产品、工具和沉浸式体验,并为员工部署ChatGPT以提升工作效率。


总体来看,这项合作并非单纯的财务投资,而是迪士尼深度整合OpenAI生成式AI能力的战略举措。


根据协议安排,预计从2026年初起,OpenAI的Sora和ChatGPT Images等图像与视频生成产品将正式支持迪士尼旗下众多经典IP角色,用户届时可在创作中使用米老鼠、狮子王、灰姑娘、玩具总动员、疯狂动物城等作品中的角色形象。


就在同一天,迪士尼还向谷歌发出停止侵权通知函,指控谷歌在未经授权的情况下,大规模“复制”迪士尼受版权保护作品来训练生成式AI模型,还可能把“生成的IP内容”分发给消费者。


谷歌既未证实也未否认迪士尼的指控,只表示会与迪士尼进行沟通,并强调公司用开放网络的公共数据构建AI,也做了版权控制(YouTube的Content ID)


那么迪士尼为何豪掷10亿美元投资OpenAI?OpenAI又将面临着怎样的竞争格局?


一、迪士尼与奥特曼,各取所需


对于迪士尼而言,这10亿美元像是一笔“保护性投资”。


这笔投资不仅让迪士尼获得了OpenAI的少数股权和潜在的股权认购权,更重要的是,买到了三样东西:技术、渠道和规则制定权。


技术赋能方面,Sora 2有机会成为迪士尼重塑内容生产的“魔法工厂”。


迪士尼的核心竞争力在于其庞大的IP版图。从《白雪公主》到《冰雪奇缘》,迪士尼用一代代的技术革新,不断拓展着动画电影的边界。如今,Sora的出现,让迪士尼看到了内容生产的全新可能。


Sora 2作为OpenAI推出的文生视频大模型,其能力较Sora出现了长足的进步。它目前已经能够理解复杂的自然语言指令,并生成质量较高的、符合物理逻辑的视频。


这意味着未来的迪士尼动画师,可以通过向Sora 2下达“让米老鼠在上海迪士尼城堡上空,驾驶一艘《星球大战》的X翼战机,背景是绚烂的烟花”这样的指令,就能在几分钟内生成一段高质量的预览视频。这将有效压缩前期创意探索和制作的成本与时间,让创作者能将更多精力投入到核心的故事构思和具体细节的打磨上。


这笔交易最重要也最有标志性意义的部分,莫过于允许普通用户在Sora上使用迪士尼的IP进行创作。这种渠道创新标志着迪士尼开始从一个封闭的IP的所有者,向一个开放的IP平台赋能者转变。


过去,粉丝对迪士尼IP的二次创作长期处于灰色地带,而现在,迪士尼主动打开潘多拉魔盒。用户可以在Sora 2中,让钢铁侠与艾莎公主同框对话,让巴斯光年驾驶千年隼号飞船。这种“官方玩梗,最为致命”的开放姿态,将引爆UGC(用户生成内容)的巨大潜力。


此外,Sora 2中还规划了名为“Cameo”的功能,允许用户将自己的形象植入到AI生成的视频中,与喜爱的角色互动。


这些由用户创作的、带有强烈个人色彩的短视频,将通过社交网络病毒式传播,成为迪士尼IP最鲜活、最广泛的广告。


通过这种方式,迪士尼的IP不再仅仅是躺在电影和乐园里的静态资产,而是在AI的世界里获得了“赛博永生”,不断地被重新诠释、传播和喜爱。这不仅巩固了现有IP的生命力,也为未来新IP的孵化和推广提供了全新的路径。


最关键的,是迪士尼开始通过版权制高点参与AI规则制定。


迪士尼此次与OpenAI的合作,双方的合作协议中明确规定,授权不包括任何演员的肖像或声音角色将以动画或插画的形式出现。这既是对创作者权益的保护,也是在AI伦理和法律边界上的一次谨慎探索。


通过成为Sora的首个主要内容授权合作伙伴,迪士尼实际上获得了定义AI时代版权使用规则的先发优势。OpenAI也计划推出精细化的版权控制选项和收入分成模式,允许版权方自主决定其IP在Sora中的使用方式。


迪士尼通过“选择加入”并深度合作的方式,而不是像其他好莱坞公司那样简单地“选择退出”或观望,成功地将自己从一个潜在的“受害者”转变为“规则制定者”。同时给业界提供了案例:AI生成内容的版权问题,不是不能解决,而是可以通过商业合作和技术手段来规范。


这个模式一旦跑通,迪士尼就开拓了一条新的商业模式:IP授权不再局限于传统的商品、影视改编,而是扩展到了AI生成服务领域。未来,任何AI公司想要合法地使用迪士尼的IP,都必须遵循迪士尼与OpenAI共同建立的这套“Sora范式”,支付相应的授权费用。


而在同OpenAI达成深度合作的同时,迪士尼也帮奥特曼“捅”了最大的竞争对手Google一刀。


迪士尼对谷歌的诉讼,挤压了其版权的灰色空间,客观上起到了“围魏救赵”的效果。


在通用大模型技术逐渐趋同的背景下,高质量、独家的训练数据,正成为AI公司的核心竞争力。


通过与迪士尼的独家合作,OpenAI的Sora可以向合法、大规模生成顶级IP内容的AI视频平台靠拢。当用户想创作一个关于“钢铁侠大战灭霸”的短视频时,Sora会是他们的安心之选。


利用Sora强大的流量效应,更多的用户因为迪士尼IP涌入Sora平台,他们创作的UGC内容又会带来更多点击,从而为OpenAI提供用户数据和模型迭代的养料。


而谷歌的AI,即便技术上同样强大,但在内容生态的丰富性和吸引力上,将与Sora出现一定差距,从而延缓谷歌在生成式AI领域的步伐,为OpenAI争取宝贵的发展时间。


奥特曼将一个原本可能扼住自己喉咙的版权问题,巧妙地转化成了一把可以卡住对手脖子的利剑,这一招“借刀杀人”,不可谓不高明。


二、被拉下技术神坛后,OpenAI也得打“堑壕战”


不过,OpenAI虽拿下了迪士尼的投资与授权,为自身在内容生成和娱乐领域的布局增添了重要砝码,但它最近的日子,可实在算不上好过。


而这一切都要从Google发布的最新模型Gemini-3说起。


作为谷歌的最新一代基础模型,Gemini 3完成了对GPT-5.1的全方位压制,在多模态理解、复杂任务处理方面,都展现出了令人咋舌的“代差级”领先。


如果说过去的AI只是“看图识字”,那么Gemini 3就是真正拥有了“火眼金睛”。


Gemini 3不只是识别出画面里有什么,而是能彻底理解图像、视频和声音之间的复杂关联。而在视频理解测试中,Gemini 3的得分遥遥领先,实现了从“看懂”到“理解关联”的跨越,无疑给内容生成与专业分析领域带来了一场革命。


更强大的是其在处理复杂任务时的全能性。Gemini 3拥有超长的上下文记忆能力,能一次性“吃透”相当于一整本学术论文的信息,并且保持惊人的信息保留率。这也让它在真实任务中展现出远超同类模型的能力。


这种全方位且极其明显的技术领先,把OpenAI打了个措手不及。


晨星公司科技股票高级策略师布莱恩·科莱洛表示:“人们越来越觉得Alphabet(Google母公司)具备成为人工智能模型构建领域主导者的所有条件。就在几个月前,投资者还会把这个头衔颁给OpenAI。但现在情况更加不明朗,竞争更加激烈,OpenAI最终能否胜出也存在更大的风险。”


为了应对Google的冲击,CEO奥特曼宣布公司进入“红色代码”状态,全力提升ChatGPT的质量,并因此推迟其他产品的开发。


其中最明显的牺牲,就是暂时叫停了关键的广告变现业务节奏。


据OpenAI最新发布的SP显示,到2030年时,广告相关业务营收预计将高达450亿美元,占到当年营收的20%以上,这本是OpenAI最重要的未来“现金奶牛”之一。但在Gemini 3的强大冲击下,其一直在测试利用ChatGPT数百万用户的搜索行为投放购物广告的计划也被突然叫停,可见压力之大。


而在资本市场上,选择和OpenAI一边“站队”的企业,也被市场用真金白银投出了不信任的一票。


作为OpenAI云计算基础设施和核心服务的战略合作伙伴,Oracle这几月以来在资本市场上的表现就像坐上了过山车一般,忽上忽下。


在今年三季度期间,Oracle与OpenAI签署了一份长达五年、总价值高达3000亿美元的云计算服务大单,这笔巨大的合作订单曾让Oracle的股价在短时间内一夜大涨近40%,市场将其视为AI时代的“卖铲人”而热捧。


但随着OpenAI的技术“落后”之势越发明显,市场对这份合作的前景也开始产生担忧,认为这笔巨额订单的未来存在变数。受此影响,Oracle的股价在短短三个月内,几近遭遇腰斩,充分体现了资本对OpenAI的不看好。


然而,这还仅仅是OpenAI面临的“开胃菜”。当前,OpenAI身上背负着一系列令人咋舌的算力采购或租赁订单,涉及到的金额和规模极为庞大:包括英伟达、AMD、博通等上游硬件巨头的数百亿美元订单,以及与微软、AWS等云服务提供商高达数千亿美元的巨额合作。


如此巨大的算力投入,其商业逻辑是建立在模型持续领先、业务顺利落地、商业化路径能够跑通这一系列假设基础之上的。


但是,现在最核心的模型能力却被公开质疑,相当于直接动摇了OpenAI的立命之本。一旦技术优势不再,随之而来的就是商业模式的信任危机。


在模型落后的大背景下,OpenAI依赖巨额投入建立的商业“正循环”,这个本应通过领先模型吸引用户和收入、再用收入继续购买算力研发模型的模式,能够顺利达成的概率,可以说是少之又少了。


不过,当前头部模型的竞争态势本就是你追我赶,瞬息万变。OpenAI在拉响红色警报后,能端出什么样的最新模型目前还未可知。


但可以确定的是,2025年底的OpenAI,与三年前那个刚刚引爆全球的OpenAI已完全不同。如今的它无疑更加成熟、更加庞大,但要面临的竞争也更加残酷和白热化。


本文来自微信公众号:超聚焦foci,作者:肖恩

下载虎嗅APP,第一时间获取深度独到的商业科技资讯,连接更多创新人群与线下活动

❌