阅读视图

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

面试官问"try-catch影响性能吗",我用数据打脸

面试官问"try-catch影响性能吗",我用数据打脸

面试的时候被问到这个问题:try-catch 会影响性能吗?

当时我有点懵,回答了一个模糊的"会有一点影响吧"。面试官追问:影响多大?什么情况下影响大?我就说不上来了。

回来之后认真研究了一下,发现这个问题的答案比想象中有意思。

先说结论

在现代 JavaScript 引擎中,try-catch 本身几乎不影响性能,但异常抛出是昂贵的操作。

听起来有点绕?用人话说就是:

  • 代码外面套一层 try-catch → 基本没影响
  • 代码里频繁 throw Error → 性能会很差

下面用数据说话。

实测数据

我写了个简单的测试:

const iterations = 1000000;

// 测试1:不用 try-catch
console.time('无 try-catch');
for (let i = 0; i < iterations; i++) {
    Math.sqrt(i);
}
console.timeEnd('无 try-catch');

// 测试2:用 try-catch,但不抛异常
console.time('有 try-catch,不抛异常');
for (let i = 0; i < iterations; i++) {
    try {
        Math.sqrt(i);
    } catch (e) {
        // 不会执行
    }
}
console.timeEnd('有 try-catch,不抛异常');

// 测试3:用 try-catch,每次都抛异常(注意迭代次数少很多)
console.time('有 try-catch,每次抛异常');
for (let i = 0; i < 10000; i++) {
    try {
        throw new Error('test');
    } catch (e) {
        // 处理异常
    }
}
console.timeEnd('有 try-catch,每次抛异常');

结果(Node.js v20,M1 Mac):

场景 迭代次数 耗时
无 try-catch 1,000,000 1.8ms
有 try-catch,不抛异常 1,000,000 1.2ms
有 try-catch,每次抛异常 10,000 13.9ms

有意思的是,加了 try-catch 反而更快了?

是的,这可能是 V8 引擎的优化效果。但重点是:只要不抛异常,try-catch 的开销可以忽略不计。

而抛异常的场景呢?迭代次数少了100倍,耗时却多了10倍。换算一下,异常抛出比正常执行慢约1000倍

为什么异常抛出这么慢?

因为 JavaScript 引擎需要做三件事:

  1. 创建 Error 对象 - 这个对象包含了错误信息
  2. 捕获堆栈跟踪 - 遍历调用栈,记录每一层的函数名、文件名、行号
  3. 展开调用栈 - 从抛出点一直往上找,直到找到匹配的 catch 块

其中第 2 步最耗时。调用栈越深,捕获堆栈跟踪的开销越大。

什么时候该用 try-catch?

记住一个原则:异常是用来处理异常情况的,不是用来控制正常流程的

正确用法:处理真正的异常

// JSON 解析可能失败
try {
    const data = JSON.parse(userInput);
    processData(data);
} catch (e) {
    showError('输入的格式不对');
}

// 网络请求可能失败
try {
    const response = await fetch('/api/data');
    const data = await response.json();
} catch (e) {
    showError('网络连接失败');
}

这些场景下,异常是"意外情况",不是每次都会发生。用 try-catch 完全没问题。

错误用法:用异常控制流程

// 错误示范:用异常来判断用户是否存在
function findUser(id) {
    try {
        return database.query(`SELECT * FROM users WHERE id = ${id}`);
    } catch (e) {
        return null;  // 用异常来返回"找不到"
    }
}

如果大部分查询都找不到用户,那每次都会抛异常,性能会很差。

正确做法是先检查,再操作:

// 正确做法
function findUser(id) {
    const result = database.query(`SELECT * FROM users WHERE id = ${id}`);
    return result || null;
}

循环里怎么用 try-catch?

这是另一个常见问题。看两种写法:

// 写法1:try-catch 在循环内
for (const item of items) {
    try {
        processItem(item);
    } catch (e) {
        console.error('处理失败:', item);
    }
}

// 写法2:try-catch 在循环外
try {
    for (const item of items) {
        processItem(item);
    }
} catch (e) {
    console.error('处理失败:', e);
}

性能上,两者差不多。因为只要不抛异常,try-catch 本身几乎没开销。

区别在于错误处理策略

  • 写法1:某一项失败了,继续处理其他项
  • 写法2:某一项失败了,整个循环终止

根据业务需求选择,别纠结性能。

关于早期 V8 的问题

网上有些老文章说"try-catch 会阻止 V8 优化",这在早期版本确实存在。但在 V8 6.0+(Node.js 8.3+,Chrome 60+)之后,这个问题已经解决了。

所以如果你看到有人说"try-catch 会让函数无法被优化",看看文章发布时间。2018 年之前的文章可以参考,但别太当真。

最佳实践总结

  1. 放心用 try-catch - 现代引擎下,性能影响可以忽略
  2. 异常是异常 - 用于处理真正的错误情况,不是控制流程
  3. 先检查,再操作 - 能用 if 判断的,别用异常处理
  4. catch 里要做事 - 空的 catch 块是代码坏味道
  5. 错误要有上下文 - catch 里记录足够的信息方便排查
// 最佳实践示例
async function fetchUserData(userId) {
    if (!userId) {
        return null;  // 先检查,不用异常
    }

    try {
        const response = await fetch(`/api/users/${userId}`);
        if (!response.ok) {
            // HTTP 错误,但不一定是异常
            console.warn(`获取用户失败: ${response.status}`);
            return null;
        }
        return await response.json();
    } catch (e) {
        // 真正的异常:网络断开、JSON 解析失败等
        console.error('获取用户数据异常:', {
            userId,
            error: e.message,
            stack: e.stack
        });
        throw e;  // 根据需要决定是否重新抛出
    }
}

面试怎么答?

下次再被问到这个问题,可以这样回答:

try-catch 本身在现代 JavaScript 引擎中几乎没有性能开销。真正影响性能的是异常的抛出和捕获,因为需要创建 Error 对象和捕获堆栈跟踪。所以建议把 try-catch 用于处理真正的异常情况,而不是用来控制正常的程序流程。比如用户输入校验,应该用 if 判断而不是 try-catch。


如果你觉得这篇文章有帮助,欢迎关注我的 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

使用命令行工具 ogr2ogr 将 CSV 转换为 Shp 数据(二)

前言

ogr2ogr 是一个数据转换工具,可用于在文件格式之间转换简单要素数据。它还可以在转换过程中执行各种操作,如空间或属性选择、减少属性集、设置输出坐标系,甚至在转换过程中重新投影特征。

如有兴趣,可参阅文章:

GDAL 实现将 CSV 转换为 Shp 数据(一)[1]

ogr2ogr 作为 GDAL(Geospatial Data Abstraction Library)库中的一个命令行工具,用于转换和处理地理空间数据。它可以读取多种矢量数据格式(如Shapefile、GeoJSON、KML、GML、PostGIS等),并进行格式转换、坐标转换、属性过滤、几何操作等。

1. 用户环境

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

时间:2025年

系统:Windows 11

Python:3.11.7

GDAL:3.11.1

ogr2ogr:3.9.2

2. 验证 ogr2ogr 工具信息

打开命令行终端,输入命令ogr2ogr --version查看版本信息。如果ogr2ogr正确安装并添加到环境变量,便会输出其版本号。

3. 将CSV转换为Shp数据

原始CSV数据内容显示如下,具有四个字段,分别为“名称”、“地址”、“LATQ”代表纬度,“LNGQ”代表经度。

在命令行终端输入以下命令进行数据转换。

ogr2ogr -f "ESRI Shapefile" E:datatest_dataogr_transform.shp E:datatest_data景点.csv -oo X_POSSIBLE_NAMES=LNGQ,longitude,x,X -oo Y_POSSIBLE_NAMES=LATQ,latitude,y,Y  -a_srs EPSG:4326

以下是ogr2ogr工具常用参数解释:

选项 说明
-f "ESRI Shapefile" 指定输出格式,如“ESRI Shapefile”
-a_srs EPSG:4326 设置空间参考
-select "field1,field2" 选择特定字段
-where "value > 100" 过滤条件
-lco ENCODING=UTF-8 设置字符编码
-oo AUTODETECT_TYPE=YES 自动检测字段类型
-oo SEPARATOR=TAB 设置分隔符(TAB/COMMA/SEMICOLON)
-oo X_POSSIBLE_NAMES 指定经度字段,可书写多个可能值
-oo Y_POSSIBLE_NAMES 指定纬度字段,可书写多个可能值

4. 中文转换错误解决办法

由于CSV文件中具有中文字段,直接进行转换工具会报错,无法创建中文字段。此时需要指定正确的字符编码。

在命令中添加-lco ENCODING=UTF-8选项以正确转换中文字符。

ogr2ogr -f "ESRI Shapefile" E:datatest_dataogr_transform.shp E:datatest_data景点.csv -oo X_POSSIBLE_NAMES=LNGQ,longitude,x,X -oo Y_POSSIBLE_NAMES=LATQ,latitude,y,Y -lco ENCODING=UTF-8 -a_srs EPSG:4326

将CSV转换为Shp数据在ArcGIS中的显示结果。

5. 常见字符编码

以下是一些常见编码格式。

# UTF-8(推荐)
-lco ENCODING=UTF-8

# 中文编码
-lco ENCODING=GBK      # 简体中文
-lco ENCODING=BIG5     # 繁体中文
-lco ENCODING=GB2312   # 简体中文旧版

# 其他编码
-lco ENCODING=LATIN1
-lco ENCODING=ISO-8859-1
-lco ENCODING=CP1252

6. 数据转换事项

使用ogr2ogr工具进行数据转换时如果所有的命令都写在一行,那么显示效果不仅不美观,也不具有可读性,所以可按照转换内容对其进行分行展示。在windowslinux系统中换行符略有差别。

windows系统进行分行展示时使用符号"^"进行分隔:

ogr2ogr -f "ESRI Shapefile" "E:\datatest_data\ogr_transform5.shp" "E:\data\test_data\景点.csv" ^
-oo X_POSSIBLE_NAMES=LNGQ,longitude,x,X ^
-oo Y_POSSIBLE_NAMES=LATQ,latitude,y,Y ^
-lco ENCODING=UTF-8 ^
-a_srs EPSG:4326

linux系统进行分行展示时使用符号"``"进行分隔

ogr2ogr -f "ESRI Shapefile" "E:\datatest_data\ogr_transform5.shp" "E:\data\test_data\景点.csv" ^
-oo X_POSSIBLE_NAMES=LNGQ,longitude,x,X ^
-oo Y_POSSIBLE_NAMES=LATQ,latitude,y,Y ^
-lco ENCODING=UTF-8 ^
-a_srs EPSG:4326

为什么 JavaScript 可以单线程却能处理异步?

今天咱们聊一个前端最核心的机制:JavaScript 单线程却能处理异步操作的秘密——事件循环(Event Loop)

你可能遇到过这种情况:用 setTimeout 延迟执行代码,或者用 fetch 发网络请求时,主线程并没有卡住,反而能继续执行后面的代码。明明 JS 是单线程的(同一时间只能做一件事),这是怎么做到的?

别急,我会用 “底层机制+代码演示+流程拆解” 的方式,带你彻底搞懂事件循环的运行原理。

前置知识:JS 单线程的本质

什么是单线程?

单线程指的是 JavaScript 引擎(如 V8)的主线程同一时间只能执行一个任务。这意味着:

  • 所有同步代码必须按顺序执行,前一个任务没完成,后一个任务必须等待;
  • 如果有一个任务耗时很长(比如死循环、大量计算),后面的任务会被“阻塞”(卡住)。

举个栗子

console.log('开始');
while (true) {  // 死循环(耗时任务)
  // 主线程被卡死,后面的代码永远执行不到
}
console.log('结束');  // 永远不会输出

这就是单线程的“缺陷”——容易阻塞。但 JS 又必须处理异步操作(比如网络请求、文件读取),否则早就被淘汰了。

为什么 JS 必须支持异步?

因为浏览器需要处理大量“耗时但不阻塞主线程”的操作:

  • 网络请求(fetchXMLHttpRequest);
  • 定时器(setTimeoutsetInterval);
  • 事件监听(点击、滚动);
  • 文件操作(Node.js 中的 fs 模块)。

如果这些操作都用同步方式执行,浏览器会直接“假死”(比如点一个按钮要等 3 秒才能响应)。所以 JS 必须设计一套机制,让这些耗时操作“在后台执行”,不阻塞主线程。

事件循环的核心角色:谁在“调度”任务?

要理解事件循环,必须先明确 JS 运行时的几个核心“角色”:

角色 职责 所属环境
调用栈(Call Stack) 存储同步任务的执行上下文(函数调用链),按“后进先出”顺序执行 JS 引擎(V8)
Web APIs 浏览器提供的“后台线程能力”(如定时器、网络请求、DOM 事件监听) 浏览器(非 JS 引擎)
任务队列(Task Queue) 存储异步任务完成后的“回调函数”,等待事件循环调度执行 浏览器
事件循环(Event Loop) 不断检查调用栈是否为空,若为空则从任务队列中取出回调函数执行 浏览器

一句话总结
JS 主线程(调用栈)只负责执行同步代码;遇到异步操作(如 setTimeout),就交给 Web APIs 后台处理;Web APIs 完成任务后,把回调函数“丢”到任务队列;事件循环负责“盯着”调用栈,一旦调用栈空了,就从任务队列里“捞”回调函数来执行。

事件循环的执行流程(附代码演示)

基础流程:同步任务 → 微任务 → 宏任务

事件循环的执行顺序有一个核心规则:同步任务优先,微任务(Microtask)次之,宏任务(Macrotask)最后

关键概念区分:

  • 同步任务:直接在调用栈中执行的任务(如 console.log、普通函数调用);
  • 微任务:由 Promise.thenMutationObserver 等 API 产生的回调,优先级高于宏任务;
  • 宏任务:由 setTimeoutsetIntervalI/O、DOM 事件等 API 产生的回调,优先级低于微任务。

代码演示 1:基础执行顺序

console.log('1: 同步任务');

setTimeout(() => {
  console.log('3: 宏任务(setTimeout)');
}, 0);

Promise.resolve()
  .then(() => {
    console.log('2: 微任务(Promise.then)');
  })
  .then(() => {
    console.log('4: 微任务(另一个 Promise.then)');
  });

输出顺序1 → 2 → 4 → 3

执行流程拆解

  1. 执行同步任务:调用栈先执行 console.log('1'),输出 1

  2. 处理异步任务

    • 遇到 setTimeout,将其交给 Web APIs 的定时器线程处理(定时 0ms);
    • 遇到 Promise.resolve().then(),将其回调(第一个 then)加入微任务队列;
  3. 同步任务执行完毕:调用栈清空;

  4. 执行微任务:事件循环检查微任务队列,按顺序执行两个 then 回调,输出 24

  5. 执行宏任务:微任务队列清空后,事件循环检查宏任务队列,执行 setTimeout 的回调,输出 3

进阶流程:嵌套异步任务

实际代码中,异步任务可能嵌套(比如 setTimeout 里又有 Promise),事件循环的处理逻辑依然遵循“同步→微任务→宏任务”的顺序,但需要注意每一轮事件循环只处理一个宏任务(除非宏任务队列为空)。

代码演示 2:嵌套异步任务

console.log('A: 同步任务');

setTimeout(() => {
  console.log('B: 宏任务(外层 setTimeout)');
  
  // 嵌套的微任务
  Promise.resolve().then(() => {
    console.log('C: 微任务(外层 setTimeout 内的 then)');
  });
  
  // 嵌套的宏任务
  setTimeout(() => {
    console.log('D: 宏任务(内层 setTimeout)');
  }, 0);
}, 0);

// 顶层的微任务
Promise.resolve().then(() => {
  console.log('E: 微任务(顶层 then)');
});

输出顺序A → E → B → C → D

执行流程拆解

  1. 执行同步任务:输出 A

  2. 处理异步任务

    • 外层 setTimeout 交给定时器线程(0ms);
    • 顶层 Promise.then 回调加入微任务队列;
  3. 同步任务结束:调用栈清空;

  4. 执行微任务:执行顶层 then 回调,输出 E

  5. 第一轮事件循环结束,开始处理宏任务队列:

    • 取出外层 setTimeout 的回调执行,输出 B
    • 外层 setTimeout 回调中遇到新的 Promise.then,将其加入微任务队列;
    • 外层 setTimeout 回调中遇到内层 setTimeout,交给定时器线程(0ms);
  6. 第一轮宏任务处理完毕,立即执行新产生的微任务:

    • 执行外层 setTimeout 内的 then 回调,输出 C
  7. 第二轮事件循环开始,处理宏任务队列:

    • 取出内层 setTimeout 的回调执行,输出 D

特殊场景:DOM 事件与异步

DOM 事件(如点击、滚动)的回调属于宏任务,但它们的触发时机与事件循环密切相关。

代码演示 3:DOM 事件回调

<button id="btn">点击我</button>
<script>
  console.log('1: 同步任务');

  const btn = document.getElementById('btn');
  btn.addEventListener('click', () => {
    console.log('3: DOM 事件回调(宏任务)');
    
    // 事件回调中的微任务
    Promise.resolve().then(() => {
      console.log('4: 事件回调内的微任务');
    });
  });

  setTimeout(() => {
    console.log('2: 宏任务(setTimeout)');
  }, 0);
</script>

可能的输出顺序(取决于用户何时点击按钮):

  • 如果用户在 setTimeout 回调执行前点击按钮:1 → 3 → 4 → 2
  • 如果用户在 setTimeout 回调执行后点击按钮:1 → 2 → 3 → 4

执行流程拆解

  1. 执行同步任务,输出 1

  2. 注册 setTimeout(宏任务)和 click 事件监听(宏任务);

  3. 若用户先点击按钮:

    • 点击事件触发,回调被加入宏任务队列;
    • setTimeout 回调也被加入宏任务队列;
    • 事件循环先处理 click 宏任务(因为先进入队列),输出 3
    • 执行 click 回调中的微任务,输出 4
    • 最后处理 setTimeout 宏任务,输出 2
  4. 若用户后点击按钮:

    • setTimeout 回调先被处理,输出 2
    • 然后处理 click 宏任务,输出 3
    • 最后执行 click 回调中的微任务,输出 4

为什么 JS 单线程还能“异步”?核心答案

现在回到最初的问题:JS 单线程却能处理异步,本质原因是什么?

异步操作不由 JS 引擎主线程执行

JS 引擎(如 V8)只负责执行同步代码和调度任务。像网络请求、定时器这些耗时操作,实际是由浏览器的其他线程(如网络线程、定时器线程)完成的。JS 主线程只需要“注册”异步任务(比如告诉定时器线程“3 秒后执行这个函数”),然后继续执行后面的同步代码。

事件循环负责“调度”异步回调

当异步任务完成后(比如定时器到时间、网络请求返回数据),浏览器会将对应的回调函数放入任务队列。事件循环会不断检查调用栈是否为空:

  • 如果调用栈空了,就从任务队列中取出回调函数,交给调用栈执行;
  • 如果调用栈不空(还在执行同步代码),事件循环就继续等待。

微任务和宏任务的优先级设计

通过区分微任务和宏任务,JS 可以更精细地控制异步回调的执行顺序。微任务(如 Promise.then)会在当前调用栈清空后立即执行(属于“本轮事件循环”),而宏任务(如 setTimeout)会在下一轮事件循环执行。这种设计保证了异步操作的高效调度。

事件循环的“隐藏阶段”:浏览器和 Node.js 的差异

你可能不知道:浏览器和 Node.js 的事件循环实现完全不同!虽然核心目标一致(调度异步任务),但具体阶段划分和执行顺序差异很大。

浏览器的事件循环:聚焦“渲染”与“用户交互”

浏览器的事件循环围绕 “页面渲染” 设计,核心目标是让用户界面流畅响应。它的执行流程可简化为以下步骤(简化版):

1. 执行调用栈中的同步任务 → 
2. 执行所有微任务(Microtask Queue) → 
3. 检查是否需要渲染(如距离上次渲染超过 16ms) → 
   a. 执行 UI 渲染(重排/重绘) → 
4. 从宏任务队列(Macrotask Queue)中取出一个任务执行 → 
5. 重复步骤 2~4(直到所有队列为空)

关键细节

  • 渲染时机:浏览器默认以 60Hz 刷新率(约 16ms/帧)渲染页面。如果同步任务执行时间超过 16ms,会导致“掉帧”(页面卡顿)。因此,事件循环会在每轮宏任务后检查是否需要渲染,避免长时间阻塞渲染。
  • 宏任务的“公平性”:浏览器宏任务队列是“队列优先”(FIFO),但为了保证交互响应,用户触发的事件(如点击、滚动)的回调会被“插队”到队列头部,优先执行。

代码演示:渲染与事件循环的关系

console.log('开始');

// 同步任务:耗时 20ms(超过 16ms 渲染间隔)
const start = performance.now();
while (performance.now() - start < 20) {}

// 宏任务:setTimeout
setTimeout(() => {
  console.log('setTimeout 执行');
}, 0);

// 微任务:Promise.then
Promise.resolve().then(() => {
  console.log('Promise.then 执行');
});

// 输出顺序:开始 → Promise.then → setTimeout 执行

执行流程

  • 同步任务耗时 20ms,导致页面无法在第 16ms 渲染(掉帧);
  • 同步任务结束后,先执行微任务(Promise.then);
  • 最后执行宏任务(setTimeout)。

Node.js 的事件循环:聚焦“ I/O 与异步操作”

Node.js 的事件循环是为 服务器端高并发 I/O 设计的,核心目标是高效处理大量异步 I/O 请求(如文件读写、数据库查询)。它的阶段划分更复杂,具体分为 6 个阶段(按执行顺序):

阶段 职责 对应的宏任务类型
timers 执行 setTimeoutsetInterval 的回调 定时器相关宏任务
I/O callbacks 处理上一轮未完成的 I/O 错误回调(如 TCP 连接错误) 系统级 I/O 错误回调
idle/prepare 内部使用,仅在 Node.js 启动时执行
poll 核心阶段:等待新的 I/O 事件(如文件读取完成、网络请求响应) 大部分 I/O 相关宏任务(除 setImmediate
check 执行 setImmediate 的回调 setImmediate 宏任务
close callbacks 执行关闭事件的回调(如 socket.on('close') 连接关闭相关回调

关键细节

  • setImmediate vs setTimeout(0)
    • setImmediate 的回调在 check 阶段执行(本轮事件循环的最后阶段);
    • setTimeout(0) 的回调在 timers 阶段执行(下一轮事件循环的开始阶段)。
      因此,setImmediate 永远比 setTimeout(0) 先执行(在 Node.js 中)。

代码演示:Node.js 事件循环阶段

const fs = require('fs');

// 阶段 1:timers(setTimeout)
setTimeout(() => {
  console.log('1: setTimeout(timers 阶段)');
}, 0);

// 阶段 4:poll(fs.readFile 是 I/O 操作)
fs.readFile(__filename, () => {
  console.log('2: fs.readFile 回调(poll 阶段)');
  
  // 阶段 5:check(setImmediate)
  setImmediate(() => {
    console.log('3: setImmediate(check 阶段)');
  });
  
  // 阶段 1:timers(嵌套的 setTimeout)
  setTimeout(() => {
    console.log('4: 嵌套 setTimeout(timers 阶段)');
  }, 0);
});

// 阶段 5:check(直接调用 setImmediate)
setImmediate(() => {
  console.log('5: 顶层 setImmediate(check 阶段)');
});

输出顺序(每次运行可能略有不同,但 setImmediate 总在 setTimeout(0) 前):

1: setTimeout(timers 阶段)
2: fs.readFile 回调(poll 阶段)
3: setImmediate(check 阶段)
5: 顶层 setImmediate(check 阶段)
4: 嵌套 setTimeout(timers 阶段)

宏任务与微任务的“细分家族”:你以为的“宏任务”可能不是真宏任务

之前我们简单区分了宏任务和微任务,但实际上它们的“家族成员”各有不同。明确每个任务的来源,能帮你更精准地控制执行顺序。

宏任务(Macrotask)的常见类型

宏任务是“需要等待当前调用栈和微任务队列清空后才能执行”的任务,常见来源包括:

来源 示例 说明
定时器 setTimeoutsetInterval 基于时间的延迟执行
I/O 操作 fs.readFile(Node.js)、fetch 网络请求、文件读写等异步 I/O
UI 渲染(浏览器) requestAnimationFrame(部分场景) 浏览器渲染帧的回调
事件监听 clickscroll 等 DOM 事件回调 用户交互触发的回调
setImmediate(Node.js) setImmediate(() => {}) Node.js 特有的“本轮事件循环结束后”执行

微任务(Microtask)的常见类型

微任务是“在当前调用栈清空后立即执行”的任务,优先级高于宏任务,常见来源包括:

来源 示例 说明
Promise 回调 Promise.then()Promise.catch() Promise 状态变为 fulfilledrejected 后的回调
MutationObserver(浏览器) new MutationObserver(callback) 监听 DOM 变化的回调
process.nextTick(Node.js) process.nextTick(() => {}) Node.js 特有的“微任务队列优先级最高”的任务(比普通微任务更早执行)
async/await(本质是 Promise) async function() { await ... } await 后的代码会被包装成微任务

关键细节

  • async/await 的底层是 Promise:await 会暂停函数执行,将后续代码包装成微任务,等待 Promise 解决后执行。
  • process.nextTick 在 Node.js 中是“伪微任务”:它的优先级比普通微任务(如 Promise.then)更高,会在当前调用栈清空后立即执行(甚至在宏任务之前)。

代码演示:Node.js 中 process.nextTick 的优先级

console.log('开始');

setTimeout(() => {
  console.log('1: setTimeout(宏任务)');
}, 0);

Promise.resolve().then(() => {
  console.log('2: Promise.then(微任务)');
});

process.nextTick(() => {
  console.log('3: process.nextTick(伪微任务)');
});

console.log('结束');

输出顺序

开始  结束  3: process.nextTick  2: Promise.then  1: setTimeout

调试工具:用 Chrome DevTools“看见”事件循环

理论看懂了,但如何验证?推荐用 Chrome DevTools 的 Performance 面板 直接观察事件循环的执行过程。

步骤 1:打开 Performance 面板

  1. F12 打开 DevTools;
  2. 点击 Performance 标签;
  3. 点击左上角的 录制 按钮(或按 Ctrl+E)开始记录。

步骤 2:执行测试代码

在控制台执行以下代码:

console.log('同步任务');

setTimeout(() => {
  console.log('宏任务(setTimeout)');
}, 0);

Promise.resolve().then(() => {
  console.log('微任务(Promise.then)');
});

步骤 3:分析记录结果

停止录制后,Performance 面板会显示时间线。重点关注 Main 线程 的调用栈和任务队列:

  • 同步任务:显示为连续的调用栈(如 console.log('同步任务'));
  • 微任务:在调用栈清空后,显示为 Promise.then 的执行;
  • 宏任务:在微任务执行后,显示为 setTimeout 的回调执行。

截图示例(文字描述):

  • 时间轴 0ms:开始执行同步任务;
  • 时间轴 1ms:同步任务结束,调用栈清空;
  • 时间轴 1ms:执行微任务(Promise.then);
  • 时间轴 2ms:微任务结束,开始执行宏任务(setTimeout)。

实战避坑:事件循环的常见陷阱

陷阱 1:定时器“延迟不准”

你可能遇到过:setTimeout(fn, 100) 实际执行时间远大于 100ms。原因是 同步任务耗时过长,阻塞了事件循环,导致定时器回调无法按时执行。

示例

setTimeout(() => {
  console.log('定时器执行'); // 实际延迟可能远大于 100ms
}, 100);

// 同步任务耗时 1000ms
const start = Date.now();
while (Date.now() - start < 1000) {}

解决方案

  • 避免在主线程执行耗时操作(如大数据计算);
  • 将耗时任务拆分为小任务(用 setTimeoutrequestIdleCallback 分段执行);
  • 使用 Web Workers(浏览器)或 Worker Threads(Node.js)将任务放到后台线程。

陷阱 2:微任务“堆积导致页面卡死”

微任务会在当前调用栈清空后立即执行,如果微任务队列无限增长(如递归调用 Promise.then),会导致页面永远无法渲染(因为事件循环被微任务阻塞)。

示例

function recursiveMicrotask() {
  Promise.resolve().then(() => {
    console.log('微任务执行');
    recursiveMicrotask(); // 递归添加微任务
  });
}

recursiveMicrotask();

现象

  • 控制台会无限输出 微任务执行
  • 页面无法响应点击、滚动等交互(因为事件循环被微任务队列占满,无法执行渲染或事件回调)。

解决方案

  • 避免在微任务中递归调用自身;
  • 限制微任务的数量(如设置最大递归次数);
  • 将部分任务转为宏任务(如用 setTimeout 包裹)。

陷阱 3:Node.js 中 setImmediatesetTimeout 的“竞争”

在 Node.js 中,setImmediatesetTimeout(0) 的执行顺序可能不稳定(取决于事件循环的启动时间)。

示例

setTimeout(() => {
  console.log('setTimeout(timers 阶段)');
}, 0);

setImmediate(() => {
  console.log('setImmediate(check 阶段)');
});

可能的输出

  • 第一次运行:setTimeout 先执行(如果 timers 阶段的延迟接近 0);
  • 第二次运行:setImmediate 先执行(如果 timers 阶段没有其他任务)。

解决方案

  • 如果需要严格的执行顺序,避免依赖 setImmediatesetTimeout(0) 的顺序;
  • 明确使用 setImmediate 表示“本轮事件循环结束后执行”,setTimeout 表示“延迟指定时间后执行”。

常见误区与总结

误区 1:JS 是多线程的?

错误。JS 主线程是单线程的,但浏览器环境(如 Web APIs)是多线程的。JS 通过事件循环与这些线程协作,实现了“异步”的效果。

误区 2:setTimeout(0) 会立即执行?

错误setTimeout(0) 表示“将回调函数放入宏任务队列,等待当前调用栈和微任务队列清空后立即执行”,而不是立即执行。

误区 3:微任务比宏任务优先级高,所以会插队?

正确。微任务队列的优先级高于宏任务队列,所以每轮事件循环会先清空微任务队列,再处理宏任务队列。

事件循环的“口诀”

事件循环的核心是 “协作式调度”:JS 主线程负责同步任务,异步任务交给其他线程处理,完成后通过回调函数“排队”,事件循环按优先级(微任务→宏任务)调度执行。

同步任务先执行,调用栈里排好队;
遇到异步交后台,Web APIs 帮你忙;
定时网络事件等,完成回调放队列;
微任务队列优先级,当前循环接着跑;
宏任务队列下一轮,事件循环来回跑;
单线程不阻塞住,异步全靠事件环。

用 Turborepo 打造 Strapi 插件开发的极速全栈体验

什么是Turborepo

我在两三年前听到这个名词一直没搞懂,今天我借助AI+实践搞懂了这个架构,以及我们为什么要用它

在现代前端工程化中,"Monorepo" 已经成为了一个绕不开的热词。但很多开发者在初次接触时,容易把它和 "Multirepo" 甚至 "Turborepo" 混淆

1. Multirepo:各自为政的“独栋别墅”

Multirepo (Multiple Repositories) 是最传统的管理方式。

🏠 比喻:你的前端团队住在 A 栋别墅,后端团队住在 B 栋别墅,UI 组件库团队住在 C 栋别墅。大家互不干扰,物理隔离。

这种模式是怎样的?

  • 每个项目都有独立的 Git 仓库(例如 git/frontendgit/backend)。
  • 每个仓库有自己独立的配置(ESLint, TSConfig)。

✅ 优点

  • 权限清晰:你可以只给新人开前端仓库的权限,他看不到后端的代码。
  • 体积小git clone 很快,因为你只拉取了这一个项目的代码。

❌ 痛点

  • 协作噩梦:当你修了一个 Bug,需要同时改动前端和后端代码时,你无法在一个 Commit 里完成。
  • 依赖地狱:如果你改了 UI组件库 的代码,你得先发个 npm 包,然后去前端项目里升级版本号,再 npm install……这一套流程下来,一下午就过去了。
这种模式应该是目前大家所最常用的一种模式,这个主要是思维惯性与工具默认,早期的 CI/CD 工具(如 Jenkins)也更习惯于一个仓库对应一条流水线, 当你用 create-react-app 或 git init 时,默认就是创建一个仓库管一个项目

2. Monorepo:欢聚一堂的“大平层”

为了解决 Multirepo 的协作痛点,Google、Facebook 等大厂率先采用了 Monorepo (Monolithic Repository)

🏢 比喻:大家搬进了一栋巨大的大平层公寓。虽然每人还有自己的房间(目录),但都在同一个大门(Git 仓库)里进出。

这种模式是怎样的?

  • 所有项目都在同一个 Git 仓库里(例如 apps/webapps/docspackages/ui)。
  • 使用 Workspace(工作区)技术将它们在本地软链接起来。

✅ 优点

  • 原子提交 (Atomic Commits):你可以一次 Commit 同时修复前后端的 Bug,保证版本的一致性。
  • 代码共享零成本:你在 packages/ui 里改了按钮颜色,保存一下,apps/web 里立马生效,根本不需要发包!
  • 统一规范:大家共用一套配置,从此不再有“你用 2 空格缩进,我用 4 空格”的争吵。

❌ 新的问题

  • 构建太慢了! 以前你只构建一个项目,现在仓库里有 10 个项目。如果每次改一行代码,CI(持续集成)都要把 10 个项目全跑一遍,那就要等到天荒地老

在微服务架构流行之前(大概 2015 年以前),绝大多数项目(包括 JSP、ASP.NET、PHP)其实都是单体。那时候大家默认就是“一个仓库管所有代码”,根本不需要发明 Monorepo 这个词。后来,微服务和前后端分离流行起来了,大家把仓库拆散了(Multirepo)。再后来,大家发现拆太散了不好管,又把它们合并回来了,这时候为了区分“强耦合的单体”和“松耦合的多项目集合”,才强调了 Monorepo 这个概念


3. Turborepo:大平层的“智能管家”

这时候,Turborepo 登场了。

很多人的误区在于:“我要用 Turborepo 还是用 Monorepo?” 这是一个错误的问题。 Turborepo 不是 Monorepo 的替代品,而是它的加速器

🤖 比喻:Turborepo 是这栋大平层公寓配备的AI 智能管家

Turborepo 解决了什么?

它专门解决 Monorepo “构建慢、任务乱” 的问题。

⚡️ 核心黑科技 1:任务编排 (Pipeline)

它知道 web 应用依赖 ui 包。当你运行构建命令时,它会自动先构建 ui,而且能把互不相关的任务(比如 linttest)利用多核 CPU 并行执行

⚡️ 核心黑科技 2:智能缓存 (Caching)

这是 Turborepo 的杀手锏。当你运行构建时,它会先检查:“哎,这个文件之前构建过吗?输入变了吗?”

  • 如果没变:直接从缓存复制粘贴结果。时间消耗:几毫秒
  • 甚至支持云端缓存:你同事构建过的结果,你拉下来也能直接用!

4. 项目实践

Turborepo官网

安装

pnpm dlx create-turbo@latest

以下是我工程目录

我们的目标目录结构如下:

my-strapi-monorepo/
├── apps/
│   ├── backend/          # Strapi v5 主程序
│   └── docs/             # VitePress 文档站
├── packages/
│   ├── strapi-plugin-bag/ # 我们的插件核心代码
│   └── ui/               # (可选) 共享 UI 组件库
├── package.json
├── pnpm-workspace.yaml
└── turbo.json

这种结构清晰地分离了“宿主应用”和“插件包”,模拟了真实的 npm 包引用场景。

统一依赖管理 (Workspace)

在根目录的 pnpm-workspace.yaml 中,我们将所有项目串联起来:

packages:
  - "apps/*"
  - "packages/*"

然后,在 apps/backend/package.json 中,我们可以直接引用本地插件,无需发包,无需 npm link

"dependencies": {
  "strapi-plugin-bag": "workspace:*"
}

这意味着,strapi-plugin-bag 的任何修改,对于 backend 来说都是实时可见的。

配置 turbo.json

这是 Turborepo 的大脑。我们需要告诉它,Strapi 的构建产物在哪里,以及哪些任务需要并行。

{
  "$schema": "https://turborepo.com/schema.json",
  "tasks": {
    "build": {
      // 依赖上游的 build (例如 backend 依赖插件先 build 完)
      "dependsOn": ["^build"],
      // 告诉 Turbo 哪些文件是产物,下次命中缓存直接恢复,不用真跑
      "outputs": ["dist/**", "build/**", ".next/**"]
    },
    "dev": {
      // 开发服务器不需要缓存,且需要持久化运行
      "cache": false,
      "persistent": true
    }
  }
}

小技巧:Strapi 的产物在 build/,插件在 dist/,VitePress 在 .vitepress/dist。确保把这些目录都加到 outputs 里,Turbo 的缓存才能生效。

一键启动全栈环境

在根目录运行:

pnpm dev

Turborepo 会自动寻找所有子项目中的 dev 脚本并并行执行:

  • Backend: 启动 Strapi CMS (http://localhost:1337)
  • Plugin: 开启 TypeScript 实时编译监听
  • Docs: 启动 VitePress 文档 (http://localhost:5173)

所有日志汇聚在一个终端,互不干扰,配合极好。

精准构建

只想重新构建插件?不需要跑整个仓库:

turbo run build --filter=strapi-plugin-bag

Turbo 的 --filter 参数非常强大,支持依赖拓扑筛选。比如 turbo run build --filter=backend... 会自动先构建 backend 依赖的所有包(插件),然后再构建 backend,--filter=等于项目名字(在package.json)

📝 总结

通过引入 Turborepo,我们将分散的 Strapi 插件开发流程整合成了一个有机的整体。

  • 开发pnpm dev 一键起飞。
  • 调试:Workspace 软链接让源码修改即时生效。
  • 文档:代码和文档同仓管理,版本同步更容易。

如果你也在做 Strapi 插件开发,强烈推荐尝试这种架构,它会让你从繁琐的 cd 命令中解放出来,专注于代码本身。

image.png

总结:该怎么选?

模式 适用场景 关键特点
Multirepo 团队完全隔离,技术栈无关,项目间几乎没有代码复用。 简单,但协作成本高。
Monorepo 全栈团队,前后端紧密协作,有公共组件库或工具库。 协作爽,代码复用率高。
Turborepo 只要你用了 Monorepo,就强烈建议用它! 让 Monorepo 快得飞起。

一句话总结: Monorepo 是一种架构风格(把大家聚在一起),而 Turborepo 是工具(让大家聚在一起还能高效干活)

想偷懒购买现成的应用,结果一更新就遇到了4.3a!

背景

对于Appstore来说,除了开发者自行研发产品之外。也可以通过AppStore留下的后门,转让功能

今天就简单聊聊,一位粉丝因为产品上架不顺利,所以反手买了同类型的竞品。

交易背景

某粉丝因为自己制作的工具产品遭遇了3.2f封号的洗礼,又想快速抢回自己的市场,所以自以为聪明的购买了一套AppStore线上的产品

出发点本身没有问题,确实是解决问题最快的方式,但是忽略几个关键的信息内容:

  • 老包App上架、迭代时间
  • 账号潜在风险

老包上架时间

购买的老包上架时间为2020年06月17日,最近一次迭代时间为2021年11月17日。历时更新了13个版本,无论是从包本身还是版本迭代周期,其实产品本身肯定是健康的。毕竟活了将近5年的产品,账号的权重和产品的权重,无需多言

那为什么把这点单独提出来,是因为最近迭代更新的时间

在2021年的时候,苹果审核的算法肯定是今非昔比。这种情况不亚于,用前朝的剑斩本朝的官

直白来讲,之前更新顺利是因为苹果查的不严,竞品不多。在这种背景下,产品是健硕。无论是当时的用户交互还是UI设计,肯定是符合当下苹果开发者准则。

但时至今日,那很难说了。

账号潜在风险

交易的账号有几款产品,但是粉丝只需要其中一款。那么问题来了,之后的事情没有人可以担保不发生意外。

首先就是关联风险,这种App转让的行为是强关联。 买过来的产品继续当良民最好一些隔离措施,但是不表明卖家一直可以健健康康。如果卖家一旦出现不可控的违规行为,那么买过来的产品到底能活多久?是一个大大问号

客观来说非要转让产品,除非打包一起买,不然多增加变量。

不过话说回来,这种成本又被间接性拉高,同时未必卖家愿意。

4.3a破局

粉丝在遭遇了2次4.3a后,一时间乱了阵脚。主动在后台留言咨询,请求付费破局。在了解到整个事件来龙去脉后,耗时7天工作日,成功完成App迭代工作

主要协助解决了API接口处理,内购订阅功能,3.2f老包版本功能植入

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

相关推荐

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

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

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

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

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

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

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

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

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

【AI 编程实战】第 4 篇:一次完美 vs 五轮对话 - UnoCSS 配置的正确姿势

很多教程告诉你"写好提示词就能让 AI 一次给出正确答案",但现实是:你不可能一开始就知道所有细节。真正高效的 AI 协作,是在对话中逐步明确需求。这篇文章通过一个完整的 UnoCSS 配置案例,展示我和 TRAE SOLO 的真实交互过程——从模糊想法到完整方案,中间经历了哪些沟通和调整。

这是《AI 编程实战:TRAE SOLO 全栈开发指南》专栏的第四篇文章,带你学会如何与 AI 正确的沟通协作。

一、开篇:别被"完美提示词"骗了

1.1 一个常见的误区

网上很多 AI 教程会给你看这样的"完美提示词":

帮我配置 UnoCSS,项目环境和需求如下:
【项目环境】UniApp + Vue3 + Vite,目标平台微信小程序
【功能需求】支持 attributify、@apply、图标、安全区适配
【已知问题】小程序 transform 类需要预设 CSS 变量
【输出要求】完整的 uno.config.ts 配置文件

然后告诉你:"看,一次就能得到完美答案!"

但问题是

  • 你一开始怎么知道需要"安全区适配"?
  • 你怎么知道"transform 类需要预设 CSS 变量"?
  • 这些都是踩坑之后才知道的

1.2 真实的 AI 协作是什么样的

真实的 AI 协作是一个渐进式对话的过程:

第 1 轮:我有个模糊的需求
    ↓
AI:给出初步方案 + 执行计划
    ↓
第 2 轮:我发现问题 / 补充细节
    ↓
AI:调整方案
    ↓
第 3 轮:遇到报错 / 样式不生效
    ↓
AI:排查问题 + 修复
    ↓
最终:得到完整可用的方案

接下来,我会完整展示和 TRAE SOLO 配置 UnoCSS 的真实对话过程

二、第一轮对话:从模糊需求开始

2.1 我的第一句话

刚开始,我对 UnoCSS 的了解很有限,只知道它是"原子化 CSS"。我的第一句话很简单:

我:项目需要用 UnoCSS,帮我配置一下

2.2 TRAE SOLO 的响应:先给执行计划

TRAE SOLO 没有直接开始写代码,而是先给出了执行计划

TRAE SOLO:好的,我来帮你配置 UnoCSS。在开始之前,我需要先了解一些信息:

📋 执行计划:
1. 分析项目结构,确定技术栈
2. 选择合适的 UnoCSS 预设
3. 生成配置文件
4. 配置 Vite 插件

🤔 我有几个问题:
1. 这是什么类型的项目?(Vue/React/UniApp)
2. 目标运行平台是什么?(Web/小程序/App)
3. 有没有特别想要的功能?(图标/attributify 模式等)

请告诉我这些信息,或者我可以先分析你的项目结构来判断。

关键点:TRAE SOLO 主动询问上下文,而不是瞎猜。

2.3 我补充信息

我:UniApp + Vue3 项目,主要跑微信小程序,也要支持 H5。
    功能的话,我也不太清楚,你看着配吧,常用的都加上。

2.4 TRAE SOLO 分析项目后给出方案

TRAE SOLO:我分析了你的项目结构,发现:
- 框架:UniApp + Vue3 + Vite
- UI 库:已安装 wot-design-uni
- 目标:微信小程序 + H5

📋 更新执行计划:

1. 安装依赖
   - unocss
   - @uni-helper/unocss-preset-uni (UniApp 专用预设,必须)
   - @iconify-json/carbon (图标库,可选)

2. 创建 uno.config.ts,包含:
   - presetUni:小程序适配
   - presetAttributify:支持属性化写法
   - presetIcons:图标支持
   - transformerDirectives:支持 @apply 指令

3. 修改 vite.config.ts,添加 UnoCSS 插件

4. 在 main.ts 中引入 uno.css

是否按这个计划执行?或者你想调整什么?

2.5 我确认执行

我:可以,就按这个来
TRAE SOLO:好的,开始执行...

✅ 正在安装依赖...
✅ 创建 uno.config.ts...
✅ 修改 vite.config.ts...
✅ 修改 main.ts...

配置完成!你可以这样使用:

<view class="flex items-center gap-4 p-4 bg-white rounded-xl">
  <text class="text-lg font-bold">Hello UnoCSS</text>
</view>

运行 pnpm dev:mp-weixin 试试看效果。

三、第二轮对话:遇到问题,继续沟通

3.1 我发现样式不生效

运行项目后,我发现部分样式不生效:

我:样式大部分生效了,但是 translate-x-2 这个类没效果,
    元素没有移动

3.2 TRAE SOLO 排查问题

TRAE SOLO:让我检查一下...

我查看了生成的 CSS,发现 translate-x-2 生成的代码是:

transform: translateX(var(--un-translate-x));

问题在于:小程序环境没有预设 --un-translate-x 这个 CSS 变量。

在浏览器中,UnoCSS 会通过 :root 选择器预设这些变量,
但小程序不支持 :root 选择器。

🔧 修复方案:
在 uno.config.ts 中添加 preflights,手动为小程序元素预设变量。

是否需要我帮你修复?

3.3 我让它修复

我:帮我修复吧
TRAE SOLO:好的,我来修改 uno.config.ts...

添加了以下配置:

preflights: [
  {
    layer: 'unocss-transform-fix',
    getCSS: () => `
page, view, text, image {
  --un-translate-x: 0;
  --un-translate-y: 0;
  --un-rotate: 0;
  --un-scale-x: 1;
  --un-scale-y: 1;
  /* ... 其他变量 */
}
`,
  },
],

✅ 已修复,重新运行项目试试。

3.4 我验证修复成功

我:好了,translate 生效了

四、第三轮对话:追加新需求

4.1 我想添加安全区适配

开发过程中,我发现 iPhone 底部有安全区域遮挡内容:

我:iPhone 底部有个横条会挡住内容,UnoCSS 能处理这个吗?

4.2 TRAE SOLO 给出方案

TRAE SOLO:这是 iPhone 的安全区域问题。可以通过自定义 UnoCSS 规则来解决。

📋 方案:
添加以下自定义规则:
- pb-safe:底部安全区内边距
- pt-safe:顶部安全区内边距(刘海屏)
- p-safe:四边安全区内边距

使用方式:
<view class="pb-safe">底部内容会避开安全区</view>

是否需要我添加这些规则?
我:加上吧
TRAE SOLO:好的,在 uno.config.ts 的 rules 中添加:

rules: [
  ['pb-safe', { 'padding-bottom': 'env(safe-area-inset-bottom)' }],
  ['pt-safe', { 'padding-top': 'env(safe-area-inset-top)' }],
  ['p-safe', {
    padding: 'env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)',
  }],
],

✅ 已添加,现在可以使用 pb-safe 类了。

五、第四轮对话:优化开发体验

5.1 我觉得类名太长了

我:flex items-center justify-between 每次都要写这么长,
    有没有办法简化?

5.2 TRAE SOLO 介绍 shortcuts

TRAE SOLO:可以用 UnoCSS 的 shortcuts 功能,把常用组合定义成短类名。

比如:
- center = flex justify-center items-center
- between = flex justify-between items-center

你想定义哪些快捷方式?或者我根据你的代码分析常用组合?
我:你分析一下吧,看哪些用得多

5.3 TRAE SOLO 分析代码后给出建议

TRAE SOLO:我扫描了你的代码,发现以下组合出现频率较高:

📊 使用频率分析:
1. flex items-center (出现 23 次)
2. flex items-center justify-between (出现 15 次)
3. flex items-center justify-center (出现 12 次)
4. flex flex-col (出现 8 次)
5. bg-white rounded-xl p-4 (出现 7 次)

🔧 建议添加的 shortcuts:

shortcuts: [
  { 'center': 'flex justify-center items-center' },
  { 'between': 'flex justify-between items-center' },
  { 'col': 'flex flex-col' },
  { 'card': 'bg-white rounded-xl p-4' },
],

是否按这个配置?或者你想调整?
我:可以,就这样

六、第五轮对话:处理动态类名问题

6.1 我遇到动态类名不生效

我:我有个地方根据状态切换颜色:

    <text :class="`text-${status}-500`">状态</text>

    status 可能是 red、green、blue,但样式不生效

6.2 TRAE SOLO 解释原因并给方案

TRAE SOLO:这是 UnoCSS 的一个限制。

🔍 原因:
UnoCSS 在构建时静态扫描代码,`text-${status}-500` 这种
动态拼接的类名无法被识别,所以不会生成对应的 CSS。

🔧 解决方案有三种:

【方案一】对象语法(推荐)
<text :class="{
  'text-red-500': status === 'red',
  'text-green-500': status === 'green',
  'text-blue-500': status === 'blue',
}">状态</text>

【方案二】safelist 预声明
在 uno.config.ts 中添加:
safelist: ['text-red-500', 'text-green-500', 'text-blue-500']

【方案三】CSS 变量
<text class="text-[var(--status-color)]"
      :style="{ '--status-color': statusColor }">
  状态
</text>

你的场景用哪种方案比较合适?
我:颜色就这三种,用方案二吧,简单
TRAE SOLO:好的,已添加到 safelist:

safelist: [
  'text-red-500',
  'text-green-500',
  'text-blue-500',
  'i-carbon-code', // 之前已有的图标
],

✅ 现在动态类名可以正常工作了。

七、最终配置:多轮对话的成果

经过 5 轮对话,最终的 uno.config.ts 是这样的:

// uno.config.ts
// 这个配置是通过多轮对话逐步完善的,不是一次性写出来的

import { presetUni } from '@uni-helper/unocss-preset-uni'
import {
  defineConfig,
  presetAttributify,
  presetIcons,
  transformerDirectives,
  transformerVariantGroup,
} from 'unocss'

export default defineConfig({
  // 【第 1 轮】基础预设
  presets: [
    presetUni({
      attributify: {
        prefixedOnly: true,
      },
    }),
    presetIcons({
      scale: 1.2,
      warn: true,
      extraProperties: {
        'display': 'inline-block',
        'vertical-align': 'middle',
      },
    }),
    presetAttributify(),
  ],

  // 【第 1 轮】转换器
  transformers: [
    transformerDirectives(),
    transformerVariantGroup(),
  ],

  // 【第 2 轮】修复小程序 transform 不生效
  preflights: [
    {
      layer: 'unocss-transform-fix',
      getCSS: () => `
page, view, text, image {
  --un-translate-x: 0;
  --un-translate-y: 0;
  --un-translate-z: 0;
  --un-rotate: 0;
  --un-rotate-x: 0;
  --un-rotate-y: 0;
  --un-rotate-z: 0;
  --un-skew-x: 0;
  --un-skew-y: 0;
  --un-scale-x: 1;
  --un-scale-y: 1;
  --un-scale-z: 1;
}
`,
    },
  ],

  // 【第 3 轮】安全区适配
  rules: [
    ['p-safe', {
      padding: 'env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)',
    }],
    ['pt-safe', { 'padding-top': 'env(safe-area-inset-top)' }],
    ['pb-safe', { 'padding-bottom': 'env(safe-area-inset-bottom)' }],
  ],

  // 【第 4 轮】常用快捷方式
  shortcuts: [
    { 'center': 'flex justify-center items-center' },
    { 'between': 'flex justify-between items-center' },
    { 'col': 'flex flex-col' },
    { 'card': 'bg-white rounded-xl p-4' },
  ],

  // 【第 5 轮】动态类名预声明
  safelist: [
    'text-red-500',
    'text-green-500',
    'text-blue-500',
    'i-carbon-code',
  ],

  // 【第 1 轮】主题配置
  theme: {
    colors: {
      primary: 'var(--wot-color-theme, #0957DE)',
    },
    fontSize: {
      '2xs': ['20rpx', '28rpx'],
      '3xs': ['18rpx', '26rpx'],
    },
  },
})

八、组件开发:同样是对话式协作

8.1 开发回复卡片组件

配置完成后,我开始开发业务组件。同样是对话式协作:

我:帮我写一个回复卡片组件,用来展示 AI 生成的回复。
    左边是语气标签(比如"高情商"),中间是回复文字,右边是复制按钮。
TRAE SOLO:好的,我来帮你开发这个组件。

📋 执行计划:
1. 创建 ReplyCard.vue 组件
2. 定义 Props:tone(语气)、text(文本)
3. 定义 Events:copy(复制)
4. 使用 UnoCSS 原子类实现样式

🤔 几个问题:
1. 语气标签的颜色是固定的还是根据内容变化?
2. 复制按钮是图标还是文字?
3. 有没有设计稿可以参考?
我:标签颜色根据语气类型自动变,用哈希算法分配吧。
    复制按钮用图标。设计稿没有,你自己设计一个好看的。
TRAE SOLO:明白了,我来实现:

【设计方案】
- 卡片:白色背景、圆角、内边距
- 标签:小圆角、根据文字哈希分配 5 种颜色
- 文本:深灰色、支持多行
- 按钮:圆形点击区域、图标居中

开始生成代码...

8.2 TRAE SOLO 生成的组件

<!-- components/ReplyCard.vue -->
<template>
  <view class="flex items-center gap-2.5 bg-white rounded-xl p-4 pr-2">
    <!-- 语气标签 -->
    <view
      v-show="!hideTag"
      class="text-xs font-bold rounded-sm px-2 py-1 flex-shrink-0"
      :style="{ backgroundColor: tagStyle.bg, color: tagStyle.text }"
    >
      {{ tone }}
    </view>

    <!-- 回复文本 -->
    <view class="text-sm text-#1F2937 flex-1">
      {{ text }}
    </view>

    <!-- 复制按钮 -->
    <view
      class="w-9 h-9 center cursor-pointer"
      @click="$emit('copy', text)"
    >
      <image src="/static/images/home/copy.svg" mode="aspectFit" class="w-5 h-5" />
    </view>
  </view>
</template>

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

interface Props {
  tone: string;
  text: string;
  hideTag?: boolean;
}

const props = withDefaults(defineProps<Props>(), {
  hideTag: false,
});

defineEmits<{
  copy: [text: string];
}>();

// 5 种预设颜色
const COLORS = [
  { bg: '#F3E8FF', text: '#8B5CF6' },  // 紫色
  { bg: '#E6FFFA', text: '#319795' },  // 青色
  { bg: '#FFF5E6', text: '#F56500' },  // 橙色
  { bg: '#FFE4E6', text: '#E11D48' },  // 粉色
  { bg: '#E0F2FE', text: '#0284C7' },  // 蓝色
];

// 根据语气文字哈希分配颜色
const tagStyle = computed(() => {
  let hash = 0;
  for (let i = 0; i < props.tone.length; i++) {
    hash = (hash + props.tone.charCodeAt(i)) % COLORS.length;
  }
  return COLORS[hash];
});
</script>

8.3 我提出修改意见

我:不错,但我想加一个功能:有时候不需要显示标签,能不能加个 prop 控制?
TRAE SOLO:已经有了,就是 hideTag 这个 prop。

使用方式:
<ReplyCard tone="高情商" text="回复内容" :hide-tag="true" />

设置 hide-tag 为 true 就会隐藏标签。
我:哦对,我没注意看。那再加个需求:复制成功后给个反馈
TRAE SOLO:复制反馈建议在父组件处理,因为:
1. 卡片组件应该保持简单,只负责 UI
2. 复制逻辑和 toast 提示是业务逻辑,放父组件更合适

父组件这样用:

<ReplyCard
  tone="高情商"
  text="回复内容"
  @copy="handleCopy"
/>

const handleCopy = async (text: string) => {
  await copyText(text);
  toast.success('已复制');
};

你觉得这样合理吗?还是希望把 toast 放在组件内部?
我:你说得对,放父组件更好

九、核心经验:与 AI 协作的正确姿势

9.1 不要追求"一次完美"

错误做法 正确做法
花 30 分钟写一个"完美提示词" 花 1 分钟说出需求,让 AI 提问
一次性想清楚所有细节 边做边发现问题,逐步完善
把 AI 当执行工具 把 AI 当协作伙伴,可以对话

9.2 让 AI 先给执行计划

每次提需求后,让 AI 先给出执行计划

我:[需求描述]

AI:📋 执行计划:
    1. xxx
    2. xxx
    3. xxx

    🤔 有几个问题:
    1. xxx?
    2. xxx?

    确认后我开始执行。

好处

  • 确保 AI 理解正确
  • 发现遗漏的需求
  • 可以在执行前调整方向

9.3 遇到问题时提供上下文

❌ 错误方式

我:样式不生效

✅ 正确方式

我:translate-x-2 这个类在小程序里不生效,但在 H5 正常。
    我看了生成的 CSS,是 transform: translateX(var(--un-translate-x))。
    是不是变量没定义?

9.4 让 AI 解释它的方案

当 AI 给出方案时,追问原理:

我:为什么要用 preflights?直接写 CSS 不行吗?
TRAE SOLO:preflights 是 UnoCSS 的预设样式注入机制,和直接写 CSS 的区别:

1. preflights 会被注入到所有生成的 CSS 之前,确保优先级正确
2. 可以通过 layer 控制加载顺序
3. 统一管理,修改时只需要改配置文件

直接写 CSS 也可以,但需要确保加载顺序正确,否则可能被覆盖。

好处:理解原理后,下次遇到类似问题可以自己判断。

十、总结:对话式协作 > 完美提示词

10.1 本文的核心观点

  1. 不要追求"一次完美的提示词",真实的 AI 协作是多轮对话
  2. 让 AI 先给执行计划,确认后再执行
  3. 遇到问题时提供上下文,帮助 AI 定位问题
  4. 追问原理,理解 AI 的方案,下次才能举一反三

10.2 5 轮对话完成的工作

轮次 需求 成果
第 1 轮 基础配置 presets、transformers、theme
第 2 轮 修复 transform 问题 preflights
第 3 轮 安全区适配 自定义 rules
第 4 轮 简化常用类名 shortcuts
第 5 轮 动态类名支持 safelist

10.3 下一篇预告

《【AI 编程实战】第 5 篇:Pinia 状态管理不会写?让 AI 手把手教你最佳实践》

下一篇继续展示对话式协作,教你:

  • 如何让 AI 设计 Store 结构
  • 状态持久化的对话过程
  • 用户登录态管理的多轮沟通

与 AI 协作的关键不是"写出完美提示词",而是学会和 AI 对话

如果这篇文章对你有帮助,请点赞、收藏!

Flutter运行iOS26真机的两个问题

一、Debug编译崩溃

../../../flutter/third_party/dart/runtime/vm/virtual_memory_posix.cc: 428: error: mprotect failed: 13 (Permission denied)

在iOS上Dart不管是JIT运行还是进行hotload的时候,都需要涉及代码在内存从RW变成RX的调整,而这在Flutter低版本是通过mprotect完成,而这在iOS 26被禁止了

所以Flutter在Debug模式下真机运行iOS 26设备就崩溃了。如果确实需要在iOS 26上真机调试,方案如下:

  1. 目前测试最新的3.38.5版本可以正常在Debug模式运行iOS 26设备。
  2. 如果不能升级Flutter版本,只能暂时使用Release或Profile模式运行(修改iOS项目Scheme,设置run->Build Configuration)

二、Flutter适配UIScene

当我的iOS 26的设备正常运行后,发现Xcode控制台有一行打印:

UIScene lifecycle will soon be required. Failure to adopt will result in an assert in the future.

强制使用UIScene不然未来就可能触发断言,太可怕了😱那就顺便做了吧。

Flutter最低版本

environment:
  sdk: ^3.10.0
  flutter: ">=3.38.0"

方案一: 自动化迁移,适合iOS AppDelegate文件没有大量定制化修改,在项目终端运行命令:

flutter config --enable-uiscene-migration

迁移成功后,会在构建日志中看到 "Finished migration to UIScene lifecycle" 的提示,否则工具会给出警告,并提示你手动迁移。

方案二: 手动迁移

  1. AppDelegate移除window生命周期相关的方法,使用UIScene lifecycle的对应方法,保留application(_:didFinishLaunchingWithOptions:)方法,移除所有创建和设置window的代码
  2. AppDelegate类遵循协议:FlutterImplicitEngineDelegate,并在didInitializeImplicitFlutterEngine方法中注册插件,并将创建Method Channels或Platform Views的逻辑都迁移到此处,因为在didFinishLaunchingWithOptions执行时,FlutterViewController可能还不存在
import Flutter
import UIKit

@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
    override func application(
    _ application: UIApplication, 
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }
  
    func didInitializeImplicitFlutterEngine(_ engineBridge: any FlutterImplicitEngineBridge) {
        GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
        // Channel、Platform Views
    }
}
  1. 创建SceneDelegate.swift文件,基础定义如下:
import UIKit
import Flutter

class SceneDelegate: FlutterSceneDelegate {
}
  1. info.plist添加Application Scene Manifest相关key:
<key>UIApplicationSceneManifest</key>
<dict>
    <key>UIApplicationSupportsMultipleScenes</key>
    <false/>
    <key>UISceneConfigurations</key>
    <dict>
        <key>UIWindowSceneSessionRoleApplication</key>
        <array>
            <dict>
                <key>UISceneConfigurationName</key>
                <string>Default Configuration</string>
                <key>UISceneDelegateClassName</key>
                <string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
                <key>UISceneStoryboardFile</key>
                <string>Main</string>
            </dict>
        </array>
    </dict>
</dict>

key缺失可能会导致启动黑屏或控制台打印xxxkey缺失,其中key:

  • UISceneConfigurationName:Default Configurationflutter均可
  • UISceneDelegateClassName:FlutterSceneDelegate$(PRODUCT_MODULE_NAME).SceneDelegate均可

到此基础配置就完成了,恭喜你demo项目可以正常运行了,如果你的项目其他定制化修改还需要迁移,详细可以参考下面的Flutter Docs

参考链接

flutter项目大量使用.obs会导致项目性能极度下降吗

简短的回答是:一般情况下不会,甚至在很多场景下,大量使用 .obs 反而比传统的 setState 性能更好。

但凡事都有“但是”。如果使用姿势不对,确实会导致内存占用增加或帧率下降。

我们需要从**内存(Memory)CPU/渲染(Rendering)**两个维度来分析这个问题。

1. 内存维度:会有额外开销,但通常可忽略

  • 原理:一个普通的 int a = 0 在内存中占用极小。但是 var a = 0.obs 实际上创建了一个 RxInt 对象。这个对象内部维护了一个 Stream(流)和监听器列表。
  • 影响Rx 类型的对象确实比原始类型(Primitive types)重。
  • 结论:除非你创建了数百万个 .obs 对象(例如在一个巨大的循环中为每个数据点都创建独立的 Rx 变量),否则现代手机的内存(6GB+)完全可以忽略这点开销。对于常规 App 的几百上千个状态变量,几乎没有影响。

2. CPU/渲染维度:这是关键(双刃剑)

GetX 的 .obs + Obx 机制是细粒度更新

✅ 优势:它为什么快?

当你使用 setState 时,Flutter 会重建当前 Widget 及其子树。 当你使用 Obx 时,GetX 只会重建包裹在 Obx 内部的那个小组件

  • 例子:页面有一个复杂的列表,顶部有一个计数器。
  • setState:计数器变了 -> 整个页面 build 方法重跑 -> 列表也要做 diff(虽然 Element 树可能不重绘,但 diff 也要耗 CPU)。
  • .obs:计数器变了 -> 只有 Obx(() => Text(...)) 这一行代码重跑 -> 列表完全不受影响。
  • 结论:在这种场景下,大量使用 .obs 反而提升了性能。

❌ 陷阱:什么时候会导致性能下降?

如果你遇到了性能下降,通常是因为犯了以下错误:

1. 滥用 Obx 包裹范围过大(最常见) 错误做法:

// ❌ 错误:只要 count 变了,整个 Column 里的所有东西(包括复杂的 ChartView)都会被重建
Obx(() => Column(
  children: [
    Text("Count: ${controller.count}"),
    SuperComplexChartView(), // 极度消耗性能的组件
    AnotherHeavyWidget(),
  ],
));

正确做法:

// ✅ 正确:count 变了,只有 Text 重建,下面的复杂组件纹丝不动
Column(
  children: [
    Obx(() => Text("Count: ${controller.count}")),
    SuperComplexChartView(),
    AnotherHeavyWidget(),
  ],
);

**2. 在高频更新中使用 .obs** 如果你有一个 .obs 变量绑定在 AnimationController 的回调里,或者用于监听滚动位置(ScrollPosition),每秒更新 60-120 次。

  • 如果在 Obx 里做了复杂的逻辑运算,会导致 UI 线程掉帧。
  • 建议:对于极高频的更新,考虑使用 GetBuilder(手动 update())或者直接操作 RenderObject,不过通常 Obx 也扛得住,只要别在里面做耗时操作。

3. 大列表的 RxList 陷阱

final list = <Item>[].obs;

当你调用 list.add()list.refresh() 时,所有监听 listObx 都会重建。 如果你的 ListView 是这样写的:

Obx(() => ListView.builder(
  itemCount: controller.list.length,
  itemBuilder: (context, index) {
     // ...
  }
))

这通常没问题,因为 ListView.builder 只有视口内的 item 会渲染。但如果你在 Obx 里直接用了 Column 渲染一个长列表,那就会卡顿。

4. 滥用 Workers (ever, debounce, interval) 如果你定义了大量的 .obs 变量,并且给每一个都绑定了 ever 监听器(每次变化都执行回调),这会消耗大量的 CPU 资源,尤其是在批量更新数据的时候。

总结与建议

大量使用 .obs 不会让项目变卡,前提是你遵循“最小更新原则”。

  • 放心用:在 ViewModel (Controller) 中定义几十上百个 .obs 变量完全没问题。
  • 小心用:在 View 层写 Obx(() => ...) 时,范围越小越好。只包裹那个变化的 Text 或 Icon,不要包裹整个 Scaffold 或 Column。
  • 替代方案:如果你非常在意内存(例如在低端 IoT 设备上跑 Flutter),或者状态更新不需要响应式流的特性,可以使用 **GetBuilder + update()** 模式。它是最轻量级的,几乎等同于原生的性能,内存占用比 .obs 少,但写起来稍微麻烦一点(需要手动触发更新)。

一句话建议:继续用 .obs,但要养成把 Obx 放在 Widget 树叶子节点(最底层)的好习惯。

再次紧急修复,Flutter 针对 WebView 无法点击问题增加新的快速修复

前几天我们刚聊了 《Flutter 官方正式解决 WebView 在 iOS 26 上有点击问题》 ,这是一个完整的底层重构修复,整个修复周期审核堪比“博士论文”,但是也带来了一个问题,它只修复了 Engine 和 Framework 层面问题,那插件端还需要等升级适配修复,这链路就又再一次拉长了

所以针对这个场景,作者又提交了一个“骚操作”的快速修复,#179908 这个 PR 的修复方案非常“暴力”但也有效:找到那些特定的手势识别器,先禁用它们,然后立即重新启用, 这相当于重置了识别器的状态。

是不是又有熟悉的味道?不理解的可以看上上篇讲这个点击问题的内容。

为什么需要这个新的 PR ?因为这是一个无需任何插件更新的快速修复方案,并且也已经合并到了 master :

这个 PR 具体的代码修改就是:在 FlutterTouchInterceptingView 中添加了两个核心的辅助方法,并在 blockGesture 中调用:

  • searchAndFixWebView : 一个递归函数,它会遍历视图层级,如果遇到的视图是 WKWebView 类型,它就会调用修复手势的方法,执行 searchAndFixWebViewGestureRecognzier ,确保即使 WKWebView 被嵌套在其他 UIView 中也能被找到

  • searchAndFixWebViewGestureRecognzier : 也是一个递归函数,遍历当前视图的所有 gestureRecognizers ,检查识别器是否启用,并且类名是否用 "TouchEventsGestureRecognizer" 结尾 (通常对应 WKTouchEventsGestureRecognizer) ,然后执行 recognizer.enabled 的关闭和打开操作:

  • 修改了 blockGesture , 当手势拦截策略为 FlutterPlatformViewGestureRecognizersBlockingPolicyEager时,在 iOS 26 改为直接调用 [self searchAndFixWebView:self.embeddedView]; 来执行上述修复逻辑:

最后,方案还增加了一个 FLTDisableWebViewGestureReset ,给开发者添加了一个安全阀,通过读取 Info.plist 中的 FLTDisableWebViewGestureReset ,如果这个修复方案上线后出现严重问题,开发者可以通过配置这个 flag 来禁用这个“重置手势”的逻辑。

可以看到,这是一个快速且粗暴的改动,就是在 FlutterPlatformViews.mm 中实现了针对 WKWebView 手势识别器的递归搜索和“重启”机制,并在 blockGesture 中针对 iOS 26+ 启用了这个机制

但是好处也很明显,可以什么插件都不改就生效,当然主要是一个临时修复,为的是方便开发者快速解决问题,真正 fix 的途径还是推荐走之前的 hitTest :

参考链接

github.com/flutter/flu…

iOS逆向-哔哩哔哩增加3倍速播放(4)- 竖屏视频·全屏播放场景

Xnip2025-12-16_11-22-40.jpg

前言

作为哔哩哔哩的重度用户,我一直期待官方支持 3 倍速播放,但该功能迟迟未上线。于是,我利用 iOS 逆向工程知识,为 B 站 App 添加这一功能。

修改前:最高仅支持 2.0 倍速。

Screenshot 2025-12-11 at 07.26.05.png

修改后:成功添加 3.0 倍速选项

Screenshot 2025-12-11 at 07.22.57.png

本系列分为多篇,本文聚焦 竖屏视频·全屏播放 场景下的 3 倍速实现。

系列回顾

场景说明

本文分析的具体场景为:竖屏视频全屏播放

499416D4-1488-4417-89CD-E42861795807.png

开发环境

  • 哔哩哔哩版本:8.41.0

  • 逆向框架:MonkeyDev

  • 反汇编工具:IDA Professional 9.0

  • IDA插件:patching

  • UI 调试工具:Lookin

分析

1. 播放速度组件定位

通过 Lookin 分析 UI 层级可以发现,播放速度面板对应的视图组件为:

VKSettingView.TabContent

该组件内部持有一个 VKSettingView.TabModel,用于描述播放速度相关的数据模型。

157920C7-792E-4FEC-AF23-1AA783CBC33D.png

import Foundation

class VKSettingView.TabContent: VKSettingView.BaseContent {
  /* fields */
    var model: VKSettingView.TabModel ?
    var lazy selecter: VKSettingView.VKSelectControl ?
} 

2. TabModel 结构分析

Mach-O 中导出的 Swift 文件可以确认,VKSettingView.TabModel 中包含一个 items 属性,其类型为 [String],极有可能即为 播放速度数组

进一步在 IDA 中查看该类的方法实现,可以发现 items 对应的 setter 方法为:sub_10D8B5FA8

class VKSettingView.TabModel: VKSettingView.BaseModel {
  /* fields */
    var icon: String
    var itemsSize: __C.CGSize
    var items: [String]
    var selectedIndex: Int
    var dynamicSelectedString: String?
    var enableRepeatSelect: Swift.Bool
    var selectChangeCallback: ((_:))?
  /* methods */
    func sub_10d8b5bc4 // getter (instance)
    func sub_10d8b5c80 // setter (instance)
    func sub_10d8b5cdc // modify (instance)
    func sub_10d8b5d64 // getter (instance)
    func sub_10d8b5dfc // setter (instance)
    func sub_10d8b5e50 // modify (instance)
    func sub_10d8b5efc // getter (instance)
    func sub_10d8b5fa8 // setter (instance)
    func sub_10d8b5ff8 // modify (instance)
...
}

41E4F621-7F23-4445-A24D-0347CB125655.png

3. items 赋值来源追踪

  • 尝试直接对 sub_10D8B5FA8 添加符号断点并未触发,因此推断该属性可能通过 Objective-C Runtime 间接调用。

818F4D98-BD7D-4C7F-A134-A566E41E6D99.png

  • 结合 Swift / Objective-C 混编特性,对 -[TabModel setItems:] 添加断点后成功捕获调用。

C1BA5A52-C023-45FE-8436-EB592F188CF1.png

  • 通过 LLDB 打印参数内容可以确认:

    (0.5, 0.75, 1.0, 1.25, 1.5, 2.0)
    

    该数组正是当前 UI 中显示的播放速度列表。

LLDB:

(lldb) register read x2
      x2 = 0x000000028044efc0
(lldb) p (id)0x000000028044efc0
(__NSArrayI *) 0x000000028044efc0 @"6 elements"
(lldb) po (id)0x000000028044efc0
<__NSArrayI 0x28044efc0>(
0.5,
0.75,
1.0,
1.25,
1.5,
2.0
)

4. 调用栈分析

查看调用栈可以发现,items 的赋值逻辑来自:

-[BBPlayerPlaySettingWidgetV2 playbackRate:]

说明 播放速度数组是在该方法中被构造并传入 TabModel 的

调用堆栈:

(lldb) bt
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 10.1
  * frame #0: 0x000000011084df44 bili-universal`-[TabModel setItems:]
    frame #1: 0x0000000128d2ba78 BiliBiliTweak.dylib`_logos_method$App$VKSettingViewTabModel$setItems$(self=0x00000002821d6220, _cmd="setItems:", items=6 elements) at NJDetailPlayerAd.xm:418:5
    frame #2: 0x000000011062db8c bili-universal`-[BBPlayerPlaySettingWidgetV2 playbackRate:] + 488
    frame #3: 0x000000011062c17c bili-universal`sub_10D69410C + 112
    frame #4: 0x000000018ff78ce0 CoreFoundation`__NSARRAY_IS_CALLING_OUT_TO_A_BLOCK__ + 16
    frame #5: 0x000000018fe7cc0c CoreFoundation`-[__NSArrayM enumerateObjectsWithOptions:usingBlock:] + 192
...

5. 伪代码验证

IDA 中分析 -[BBPlayerPlaySettingWidgetV2 playbackRate:] 的伪代码,可以清晰看到播放速度数组是通过如下方式写死创建的:

v25[0] = CFSTR("0.5");
v25[1] = CFSTR("0.75");
v25[2] = CFSTR("1.0");
v25[3] = CFSTR("1.25");
v25[4] = CFSTR("1.5");
v25[5] = CFSTR("2.0");

至此可以确认:竖屏全屏场景下的播放速度选项并非动态配置,而是硬编码在该方法中

伪代码:

id __cdecl -[BBPlayerPlaySettingWidgetV2 playbackRate:](BBPlayerPlaySettingWidgetV2 *self, SEL a2, id a3)
{
...
  v4 = objc_retain(a3);
  v5 = objc_retainAutoreleasedReturnValue(-[BBPlayerObject context](self, "context"));
  v6 = objc_retainAutoreleasedReturnValue(-[BBPlayerContext status](v5, "status"));
  v7 = -[BBPlayerStatus isVerticalScreen](v6, "isVerticalScreen");
  objc_release(v6);
  objc_release(v5);
  if ( v7 )
  {
    v25[0] = CFSTR("0.5");
    v25[1] = CFSTR("0.75");
    v25[2] = CFSTR("1.0");
    v25[3] = CFSTR("1.25");
    v25[4] = CFSTR("1.5");
    v25[5] = CFSTR("2.0");
    v8 = objc_retainAutoreleasedReturnValue(+[NSArray arrayWithObjects:count:](&OBJC_CLASS___NSArray, "arrayWithObjects:count:", v25, 6LL));
    v21 = 0LL;
    v22 = &v21;
...

通用解决方案

由于播放速度数组是通过 +[NSArray arrayWithObjects:count:] 构造的,因此可以采用 全局 Hook 的方式,对该方法进行拦截与替换。

核心思路如下:

  1. 判断 count == 6
  2. 校验原始数组内容是否与默认倍速数组一致
  3. 在满足条件时返回包含 3.0 的新数组

Hook 实现代码

%hook NSArray

+ (instancetype)arrayWithObjects:(id *)objects count:(NSUInteger)cnt {
    if (cnt != 6) {
        return %orig;
    }
    NSArray *origArr = %orig(objects, cnt);
    // 用 __autoreleasing 修饰数组元素
    __autoreleasing id oldRates[] = {
        @"0.5",
        @"0.75",
        @"1.0",
        @"1.25",
        @"1.5",
        @"2.0"
    };
    NSUInteger oldRatesCount = sizeof(oldRates) / sizeof(oldRates[0]);
    // 传数组名即可,数组名会退化为指针类型 __autoreleasing id *
    NSArray *oldRatesArr = %orig(oldRates, oldRatesCount);
    if (cnt == 6 && [origArr isEqualToArray:oldRatesArr]) {
        __autoreleasing id newRates[] = {
            @"0.5",
            @"1.0",
            @"1.25",
            @"1.5",
            @"2.0",
            @"3.0"
        };
        NSUInteger newRatesCount = sizeof(newRates) / sizeof(newRates[0]);
        NSArray *newRatesArr = %orig(newRates, newRatesCount);
        return newRatesArr;
    }
    return origArr;
}

%end

最终效果

竖屏视频全屏播放场景下,播放速度列表成功新增 3.0 倍速,且不影响其他播放场景与现有功能逻辑。

Screenshot 2025-12-16 at 10.18.43.png

总结

本文通过 UI 分析、调用栈追踪与伪代码验证,完整定位了 竖屏视频全屏播放场景 下播放速度数组的生成位置,并给出了一个 稳定、通用且侵入性较低Hook 方案。

该思路同样适用于其他存在硬编码配置的功能修改场景。

代码

BiliBiliMApp-无广告版哔哩哔哩

17.88 万元起,红旗 HS6 PHEV 版上市, 全面对标方程豹钛 7

12 月 16 日,红旗品牌旗下全新一代大五座旗舰 SUV——红旗 HS6 PHEV 正式上市,新车共推出 3 款配置,指导价区间为 17.88 万 – 22.88 万元。

红旗 HS6 PHEV 定位为中大型 SUV,长宽高分别为 4925mm、1970mm、1740mm,轴距 2925mm 。

在传承品牌经典基因的同时,HS6 PHEV 融入了大量新能源设计语汇。前脸标志性的「高山飞瀑」格栅采用与车身同色的极简封闭式设计,既保留了家族辨识度,又优化了空气动力学性能。

车辆前脸采用「龙睛」厚壁光导大灯与「龙须」日行灯组合,尾部则采用「凤翎」贯穿式尾灯,通过光影层次营造科技感 。车身侧面承袭了经典的「船式车身」姿态,通过 A 柱和 C 柱反向延长线交点后移的视觉重心设计,营造出端庄稳重的气场。新车还引入了无边框外后视镜和隐藏式雨刷等细节设计,以进一步降低风阻并提升现代感 。

内饰是此次红旗 HS6 PHEV 升级的核心。新车采用「东方颐和」设计理念,试图在冰冷的科技配置之外,注入有温度的文化氛围。

红旗 HS6 的内饰细节中融入了大量中式园林元素。中央出风口采用「叠石理水」设计,拨钮模拟石形,网口模拟水纹 。中央扬声器设计为「湖心明月」造型,车门扬声器则呈现「长桥卧波」意象 。内饰配色方面,提供墨石黑、芳华红、秋实橙、林雪白四种「四时之色」,分别对应春夏秋冬的自然意境。

整个座舱前排的视觉核心是一块双 15.6 英寸 2.5K 分辨率超薄联屏,整体屏占比达 88.5% 。驾驶位前方则配备了同级领先的 30 英寸 AR-HUD,亮度高达 12000 尼特,支持强光下清晰显示及眼球追踪自动调节 。

软件层面上,红旗自研的「灵犀」系统 FAW.OS HMI 5.0 接入了「豆包」大模型,支持四音区连续对话与手势交互 ,车机系统内还深度适配了字节系与腾讯系的主流应用。

得益于 2925mm 的超长轴距,红旗 HS6 PHEV 拥有第一梯队的内部空间表现。官方称新车「得房率」达 84.2%,后排腿部空间 1070mm,肩部宽度 1510mm,头部空间 1034mm,即使身高 185cm 的乘客坐入后排,腿部仍有三拳以上余量。

同时,新车座椅也支持切换单人床、双人床及大床房三种躺卧模式,车内前后排座椅均可打通。

红旗 HS6 前排配备零重力副驾座椅,支持一键躺平、18 向调节及通风加热 ,后排的「云舒」座椅则填充了 20mm 厚的慢回弹记忆棉,并在靠背角度、坐垫长度上针对国人身材进行了专门优化(中式坐姿),中配以上车型后排同样配备通风加热功能 。

此外,新车还配备了丹拿音响系统和宽温域智能车载冷暖箱,支持 -15℃ 到 50℃ 的温度调节,可满足从冷饮到温奶的多种需求。

动力层面,红旗 HS6 PHEV 基于红旗自研「鸿鹄」插电混动平台打造,全系搭载 1.5T 混动专用发动机,热效率 45.21%,最大功率 110kW,匹配前/后电机组成两驱或四驱版本。

四驱版系统综合功率 479kW,零百加速 4.8 秒;即便在电池馈电状态下,加速时间微微增加至 5.1 秒,动力一致性优于多数竞品。纯电模式下仍可实现 5.4 秒破百,极速 190km/h。

新车中高配版配备了一块 40kWh 的磷酸铁锂电池,CLTC 纯电续航最高 248km,综合续航 1650km,WLTC 工况下最低荷电油耗 5.1L/100km,综合油耗低至 0.4L/100km,电池支持 4C 快充,20%-80% 仅需 15 分钟。

HS6 PHEV 提供「司南 100」和「司南 500」两套驾驶辅助系统。高阶版支持高速领航、城市记忆领航及跨层记忆、断头路车位、立体机械车位等复杂场景的泊车系统。

高速领航可实现点到点通行,包括自动过 ETC、上下匝道、环岛绕行等,宣称不依赖高精地图。城市记忆领航最多可学习 10 条常用路线(单条最长 100 公里),能够复现驾驶员的操作风格。

车辆的感知硬件包括 10 摄像头、1 毫米波雷达、12 超声波雷达。前视三目摄像头最远可识别 600 米外大型车辆,算法基于 BEV 鸟瞰图与 PnP-Net 预测模型,危险目标识别准确率≥99%。

安全是红旗的传统强项。

HS6 PHEV 车身高强度钢占比 75%,热成型钢达 26%,A/B 柱采用 1800MPa 一体式双门环工艺。全车配备 7 安全气囊(含远端气囊)、双预紧安全带,功能安全等级达 ASIL D,覆盖电控、制动、转向等 18 类系统。

电池安全方面,新车除通过国标全部测试外,还实现 IPX8 级防水、20 吨挤压强度、在 900℃火烧下 130 秒不起火,并搭载「电池云医生」系统,每秒采集 1500+项数据,可提前 14 天预警风险。

以往,红旗的产品定位往往偏向传统稳重。但这款 HS6 PHEV 无论从设计语言、配置丰富度还是价格策略来看,都充满了「新势力」的锐气。

依托全系标配的高通 8295P 芯片、30 英寸 AR-HUD、15.6 英寸双联屏及宽温域冷暖箱,红旗 HS6 PHEV 在配置层面显著提升了其在 20 万元级市场的竞争力。同时,通过下放金葵花车型的设计元素与安全标准,新车在提供主流智能化体验的同时,保持了红旗品牌特有的设计辨识度。

HS6 PHEV 这种兼顾科技配置与品牌底蕴的差异化定位,为消费者提供了一个更具差异化的可选项。

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

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


福瑞股份:筹划发行H股股票并在香港联合交易所有限公司上市

36氪获悉,福瑞股份公告,为进一步提高公司综合竞争力,更好地利用国际资本市场,多元化融资渠道,公司拟在境外发行股份(H股)并在香港联合交易所有限公司(以下简称“香港联交所”)主板上市(以下简称“本次发行H股及上市”)。公司将充分考虑现有股东的利益和境内外资本市场的情况,在股东会决议有效期内选择适当的时机和发行窗口完成本次发行H股及上市。

宁德时代:注销2404份股票期权

36氪获悉,宁德时代公告,公司于2025年12月5日召开董事会和监事会会议,审议通过了注销部分已授予尚未行权的股票期权的议案。鉴于公司2021 年股票期权与限制性股票激励计划中部分激励对象存在离职以及第三个行权期届满但股票期权到期未行权情形,该部分激励对象已获授但不满足行权条件的股票期权共计 2,404 份应由公司注销。

网飞秘史

本文来自微信公众号:波普商人,作者:赵山河,编辑:潮白河浩南哥、竟园大飞哥,主编:哲空空,题图来自:视觉中国


万物皆虚,万物皆允。


——《刺客信条》


最近,网飞(Netflix)搞了个大手笔:砸827亿美金,收购华纳兄弟。


很多人第一反应是,网飞这么有钱的吗?


是的,网飞就是这么有钱,市值4321.37亿美元,是迪士尼的两倍还多。


中国的爱奇艺,被称作“中国网飞”,但二者的体量,不可同日而语,硬要在咱们这找个能打的,那还是抖音上吧。


网飞买华纳兄弟,类似大财主纳妾,馋人家身子,这才一掷千金。


网飞“馋”华纳身子,“馋”的是哪个部位呢?


华纳业务分两大块,第一块是那些全球顶尖的电影厂牌和IP,如HBO,DC宇宙,哈利波特,权力的游戏等;第二块,包括CNN、TNT体育、Discovery频道在内的电视网业务。


第一块如精肉,第二块如鸡肋。


网飞买华纳,自然是取“精肉”而舍“鸡肋”。



那么,问题来了,拥有这么多大IP,这么多“精肉”,华纳为啥不能自己发展,而要“卖肉”给网飞呢?


当然不是“因为爱情”,是“因为模式”。


华纳虽是流行IP的集大成者,但它的盈利模式比较传统,属于靠买票、卖广告的传统媒体。


网飞则不然,它在全球190个国家,拥有2.8亿个付费用户,如此庞大的数字,让网飞的拍摄成本,具有了“零边际成本”的效果。


想象一下,传统院线电影,要一张票一张票的卖,而网飞这种流媒体,根本无需买票,就有2.8亿个付费用户嗷嗷待哺,并按月给网飞打钱。


如此一来,《鱿鱼游戏》这类爆款的拍摄成本,与其巨大收益相比,就可以忽略不计了。


网飞不是在卖电影或电视剧,它是在全球范围内收“电影税”。


华纳苦哈哈的卖票模式,跟网飞的“收税模式”完全不在一个维度,后者对前者是“降维打击”,这还如何竞争?


更何况,这几年,华纳累计亏损200多亿美元,员工被裁,股东黑脸,硬撑下去,有如鸡肋。


恰好此时,网飞需要华纳这边的超级IP加持,那就趁着徐娘半老,姿色尚存,把自己卖个好价钱吧!


由此可见,网飞的“全球付费会员模式”,就是网飞作为流媒体,打败传统巨头华纳的“魔法”。


放眼四周,网飞这种模式,早已“西学东渐”了。


比如腾讯的微信读书,只需每月支付一笔不到20块人民币的费用,就能成为包月的“微信读书会员”,可坐享海量图书,任意畅读,相当于拥有了一个线上的私人图书馆。


虽说中国不缺pony(马化腾)这种人才,可以copy海外的技术和模式,但不要忘了一个词——先发优势。


网飞的“包月付费会员模式”在二十多年前就诞生了,冰冻三尺非一日之寒,这让网飞成为流媒体领域难以替代的王者。


网飞创始人马克,原本的创业想法,只是在网上卖东西,进而找到了租赁售卖DVD电影光碟这个业务,一开始进展并不顺利,还差点被当时锋芒毕露的亚马逊收购。


正是在这个危急时刻,“付费订阅模式”的点子,拯救了网飞,并让其走上一条高速增长之路。


现在的网飞,风光无限,这段创业时的“生死时刻”,几乎成了一段罕有人知晓的“秘史”。


网飞秘史,不仅隐秘,更有价值。


今天,波普商人就为大家解码这段“网飞秘史”。


大祸临头


网飞创始人马克,是个“点子王”。


1997年,为了创业,他曾想出无数点子,并和好友里德,进行过精密论证,比如个性化冲浪板,定制棒球棒,定制狗粮等等。


尽管点子千奇百怪,但有个共同点,都是在网上进行销售。


经过论证,马克的大部分点子都作废了,只成功了一个,这就是——网飞。


《武林外传》里,燕小六有个口头禅,照顾好我七舅姥爷。这个口头禅,放在马克身上,可以换成——照顾好我舅太姥爷。


马克的舅太姥爷,名字叫弗洛伊德,写《梦的解析》的那位。


可能是受弗洛伊德的基因传承,马克这个“点子王”,比较善于洞悉人们的隐秘需求。


有一天,里德告诉马克,他租了张影碟,因逾期归还而被罚款,这个事让他很不爽。


马克愣了一会儿,随即拍着手说,有了,我们可以让用户在网上租录像带,说罢,掏出便签本,写下几个字——线上影碟店。


当时,DVD格式影碟,刚进入美国市场,未来如何发展,人们还在观望。


相比传统影碟,DVD购买成本更低,一张仅需20美元,运输费用在32美分左右,这让马克的“点子”有了落地的可能。


马克和里德决定创业,他们的第一步,是给“邮寄DVD”这个事业估值。


经过一番计算,他们给自己的公司估值300万美元,他们打死都不会想到,二十几年后,网飞会成为市值4000多亿美元的科技巨头。


创业伊始,马克和里德,需要一个天使投资人,幸运的是,他们都认识一个人选——里德本人。


马克出力,里德出钱,股权五五开,没过多久,里德又在估值基础上加投,稀释了股权,最终,二人成为股权三七开的合伙人。里德七,马克三。


网飞的企业文化,就是这两个人的做事方式,上班不打卡,没有强制工作时间,想来就来,想走就走。


起初,网飞还没有“算法”,他们倚赖那种“掐指一算”,还能特别准确的奇人,进货时,知道何时买30张,何时买300张。


为了库存足够充裕,每部电影,音像店会买五六份,网飞一买就是五六十份,对于《洛城机密》那样的当下热门,网飞更是500张起购。


很快,网飞在影迷中,有了这样的“名声”:拥有市面上所有DVD影碟,并且永远备有存货。


网飞的服务器,可自动记录和跟踪每一个用户,每一笔订单,乃至知道每个客户的喜好,喜欢看哪类电影。


在这个基础上,网飞搞出了从一星到五星的电影评价系统,后来,这个点子被国人借鉴,如法炮制了“豆瓣”。


网飞的影碟业务,分为销售和租赁。


开业第一个月,网飞销售额近10万美元,形势喜人,照这个增速,年收入可达100万美元。


与此同时,租金收入这块,令人不寒而栗,只有区区1000美元。


事实表明,几乎没人愿意在网飞租DVD。


为此,马克想办法营销,找到索尼,东芝,松下几家DVD巨头本地负责人,这三家公司占据DVD播放机市场90%以上份额。


马克的想法是,与东芝等企业联动,他们每卖出一台DVD机,上面都附有一个传单,写着:登录网飞网站,可获得免费租看3张DVD的机会。


一顿操作猛如虎,几个月下来,结果仍然如前——网飞卖出了很多张DVD,但就是无法说服人们去租借。


账面上,网飞靠销售DVD的收入,显得形势一片大好,员工也充满干劲,私下里,马克和里德心知肚明——大祸就要临头了。


云中顿悟


当时,网飞是唯一在网上售卖和租赁DVD的商家,但随着世纪末互联网浪潮的来袭,网飞的门口,即将“野蛮人”遍布。


博德斯连锁书店,沃尔玛,亚马逊,都在磨刀霍霍,加入“影碟线上售卖”这场商业战争。


可以想见,一旦这些巨头加入,网飞的利润空间被压缩为零,就只是个时间问题。


就在此时,里德接到了亚马逊首席财务官的电话,约他和马克来西雅图一叙,跟亚马逊的老板贝佐斯聊聊。


当时的亚马逊,刚成立没几年,是一个卖书的网站。


1998年,贝佐斯决定,亚马逊未来将不仅是一家书店,而是一个全品类的商店,虽然租赁DVD这种费事儿吧啦的业务他不会做,但很显然,贝佐斯不久就会开始销售DVD。


里德判断,一旦亚马逊开始卖DVD,网飞就会倒闭,很快倒闭。


里德拍着马克的肩膀,伙计,事情就是这样,贝佐斯来电话了,我知道他想干什么,而我们似乎没有更好的选择,只能把网飞卖给他。


听完里德的话,马克如坠冰窟。


彼时的亚马逊,刚刚上市,市值200亿美元,这家公司毫无盈利,近期也完全没有盈利打算,但贝佐斯让银行家们相信——亚马逊,就是未来。


中国的京东,高筑墙(技术壁垒),广积粮(资本支持),缓称王(盈利晚),在融资时,就曾被介绍为“中国的亚马逊”。


在马克的自传里,他对贝佐斯的外形描述颇为精彩——硕大的脑门,略尖的鼻子,谢顶的秃头,过短的脖子,这一切拼凑起来,让贝佐斯看上去,就像一只刚从壳里探出头的乌龟。


贝佐斯说话直截了当,对马克和里德说,如果网飞继续这么搞,那么最大体量,只能做到1600万美元,而亚马逊会将此作为收购价。


这个价格,坦白说,已经足以让当时的马克心动,他拥有网飞30%股份,一旦卖出,下半辈子吃喝不愁,可以直接退休,而对于里德,则没多少吸引力。


这个时候,网飞面临艰难抉择。


如果不卖给亚马逊,就要跟它竞争,仅以网飞目前销售DVD的盈利手段,可以想见,很快就会败下阵来;如果卖给亚马逊,那里德和马克,就要和这次创业说拜拜,跟他们的网飞说再见。


最后,他们选择了前者——不卖,跟亚马逊竞争。


在返程的飞机上,马克向里德提出了一个改变网飞命运的“点子”——聚焦租赁,砍掉当时网飞唯一赚钱的业务,也就是DVD零售业务,因为在这个业务上,网飞永远无法和亚马逊匹敌。


里德饶有兴趣地盯着马克,聚焦租赁,然后呢?相信不用我提醒,网飞租赁影碟的月收入是1000美元。


马克耸耸肩,这我还没想好,这是另一个“点子”。


波音飞机穿过云层,二人眼前一片苍茫。


那个点子


1999年夏,网飞处于崩溃边缘。


随着亚马逊等巨头进入线上影碟零售市场,网飞的利润一天比一天消瘦。


每天中午,马克都在办公室旁边的公园跑步,冀望挥汗如雨的某个时刻,想出令网飞起死回生的“点子”。


这天,马克来到网飞位于圣何塞市的仓库,看着眼前货架上的上万张DVD影碟发呆,一个想法在他脑海里爆炸——为什么要把所有DVD储存在仓库里?为什么不将DVD储存在用户的书架上,让他们想放多久就放多久?


带着这个“顿悟”,马克找到了里德,激动万分地对他说,还记得创业之初,你向我抱怨过什么吗?抱怨过你租的影碟逾期,被迫交了滞纳金!


里德一脸不解,那又如何?


马克说,如果我们取消滞纳金,把影碟全部储存在用户的书架上,会有什么效果?


马克向里德解释道,看电影是一种转瞬即逝的冲动,有时甚至在你开车去百视达音像店的路上,这种看电影的欲望就已经消失了。但要是,你囤了一堆DVD在那里,就可以根据自己的心情,选择看什么类型的电影。将我们的海量影碟,从仓库里“搬运”到用户的书架上,他们就可以随心所欲来选电影了,这么一来,我们最大的弱点,就能变成最大的优势。


基于马克这个鬼使神差的“点子”,网飞的“用户订阅制”诞生了:


第一,订阅制。网飞用户每月支付15.99美元,可一次性租借4张DVD,想借多久就借多久。一旦用户归还其中一张影碟,就可以到网上再租一张。


第二,序列化交付。每个网飞用户,都能创建一张自己的影碟心愿清单,把自己所有想看的电影都列上去,这样一来,当用户归还影碟时,网飞公司就可以自动将清单上的下一部电影邮寄给他。


按照这个思路,马克和里德对网飞的网页进行了修改,使得在网站上点击兑换按钮的用户中,每十人就有一人会被引导到一个自定义页面,让他们选择是否接受网飞每月15.99美元的“订阅用户计划”。


测试第一天,结果令人沸腾——90%的人自愿成为网飞的订阅用户,并提供了信用卡信息。


就这样,马克和里德,进行了数百次失败的试验,工作了数千个小时,花费了数百万美元,终于找到了一个切实可行的线上DVD影碟租赁模式。


自此之后,网飞插上了翅膀,增速惊人,一骑绝尘。


以会员订阅制为基础,网飞2007年转型流媒体。


2013年,网飞凭借爆款美剧《纸牌屋》杀入原创内容领域,其后,接连推出《鱿鱼游戏》《怪奇物语》等世界级爆款。


2025年,网飞付费用户达2.8亿,同年官宣收购华纳兄弟公司。


这所有的一切,都源自1997年夏天,网飞创始人马克的那个看似平平无奇的“点子”——在互联网上开展租赁DVD影碟业务。


部分参考书目:

《复盘网飞》,马克·伦道夫 著,中信出版集团

《奈飞文化手册》,帕蒂·麦考德 著,浙江教育出版社

《不拘一格:网飞的自由与责任工作法》:哈斯廷斯 著,中信出版集团


本文来自微信公众号:波普商人,作者:赵山河,编辑:潮白河浩南哥、竟园大飞哥,主编:哲空空

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

豹2坦克制造商KNDS计划明年上市

法德合资的防御公司KNDS决定继续推进2026年在巴黎和法兰克福进行IPO的筹备工作。先前有消息称,KNDS聘请了拉扎德(Lazard)担任顾问,可能在IPO中寻求约200亿欧元(约合233亿美元)的估值。(财联社)

华谊兄弟:阿里创投及其一致行动人马云的持股比例降至5%以下

36氪获悉,华谊兄弟公告,公司股东阿里创投于2025年12月17日通过大宗交易方式减持2952.68万股,持股比例由3.467799%降至2.403580%。阿里创投及其一致行动人马云合计持股比例由6.064215%降至4.999996%,不再是公司持股5%以上股东。此次减持有利于公司股权结构稳定,不会对公司正常经营产生不利影响。
❌