阅读视图

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

海信发布世界杯定制产品,有AI功能的电视、空调、洗衣机集中上新丨最前线

距2026美加墨世界杯开幕不足100天,作为官方赞助商,海信于3月5日在青岛一口气推出覆盖电视、空调、洗衣机等家电的全品类AI新品。

在电视上,海信把AI做成“观赛搭子”。RGB-Mini LED旗舰电视UX2026款除了强调画质提升,更突出“AI观赛”体验:支持赛中AI查询阵容及识别场上球员,并提供三场比赛同屏观看模式,赛后还能生成战术总结与复盘。

AI在空调上,承担的是“实时调度体感”。 国内首款滑盖空调——海信智慧风E5系列,主打人感与语音智控结合,通过人感识别实现风随人动、风避人吹、人近柔风、无人节能等模式。智慧风E5系列支持双人双风感与180度广角送风,在多人观赛场景中也能照顾到不同家庭成员的体感需求。

海信此次推出的650U8冰箱包括AI智控系统,还可识别 800 余种食材,自动匹配保鲜参数。

对于喜爱运动的球迷,海信新推出的四筒洗衣机,将洗鞋机集成进机身——相当于一台设备同时拥有12kg大容量洗衣机、8kg热泵烘干机、1kg婴儿/宠物洗烘一体机、1kg内衣洗烘一体机、3kg洗鞋机和护鞋机。该洗衣机还具有AI面料及脏污识别功能,可在洗鞋时实现单次洗涤超3000次拍打刷洗。

突出“AI观赛”功能的海信UX2026电视,图片:企业授权

此外,海信还为年轻人推出“大薄荷2.0套系”,在电器“更大更薄更有核”基础上,增加了全屋AI智控:当用户推开房门,AI空气管家就能调到适合的温度;打开电视,AI已选好喜欢的电视节目、适宜的亮度;AI洗护管家面对材质混杂的衣服,自动识别洗净。

在AI浪潮中,家电的边界正在被重塑——从被动执行指令到主动理解用户,产品的定义和体验也在悄然进化。

 

面试踩大坑!同一段 Node.js 代码,CJS 和 ESM 的执行顺序居然是反的?!99% 的人都答错了

一道"经典"的 Event Loop 面试题,背了八股文的你以为稳了,结果一运行却被 ESM 背刺。本文带你深挖 Node.js 事件循环中一个 99% 的人都不知道的坑:同样的代码,CJS 和 ESM 输出顺序竟然不一样。

前言

如果你准备过前端/Node.js 面试,大概率刷到过这类题目:

setImmediate(() => {
    console.log(1);
});

process.nextTick(() => {
    console.log(2);
    process.nextTick(() => {
        console.log(6);
    });
});

console.log(3);

Promise.resolve().then(() => {
    console.log(4);
    process.nextTick(() => {
        console.log(5);
    });
});

你自信地写下答案:3 → 2 → 6 → 4 → 5 → 1

面试官微微一笑,说:"没问题,回去等通知吧。"

你心满意足地回家,顺手建了个项目想验证一下,npm init,改了下 package.jsonnode index.js 一跑——

3
4
2
5
6
1

你揉了揉眼睛,又跑了一遍。没错,4 跑到 2 前面去了

你开始怀疑人生:是我八股文背错了?还是 Node.js 出 bug 了?

都不是。是 ESM 在背后捅了你一刀。


一、先复习:Node.js 事件循环到底怎么转的

在搞清楚这个坑之前,我们得先把 Node.js 的事件循环机制理清楚。这部分是基础,老手可以快速跳过,但建议还是扫一遍,因为后面的分析会用到。

1.1 事件循环的六个阶段

Node.js 的事件循环基于 libuv,分为以下几个阶段,每一轮循环(tick)按顺序执行:

   ┌───────────────────────────┐
┌─>│           timers          │  ← setTimeout / setInterval 回调
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │  ← 系统级回调(如 TCP 错误)
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │  ← 内部使用
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │           poll            │  ← I/O 回调(fs.readFile 等)
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │           check           │  ← setImmediate 回调
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │      close callbacks      │  ← socket.on('close') 等
│  └───────────────────────────┘

1.2 微任务的位置

在 Node.js v11 之后,每个阶段之间以及每个宏任务执行之后,都会清空微任务队列。微任务分为两类:

队列 API 优先级
nextTick 队列 process.nextTick()
微任务队列 Promise.then() / queueMicrotask()

也就是说,在传统认知中(CJS 模式下),优先级排序是:

同步代码 > process.nextTick > Promise 微任务 > 宏任务(timers / check / ...)

这也是为什么你面试时会回答 3 → 2 → 6 → 4 → 5 → 1 的原因——nextTick 队列会在 Promise 微任务之前被清空

1.3 微任务递归清空

一个重要的细节:nextTick 队列在清空时,如果回调中又注册了新的 nextTick,会在同一轮继续清空,直到队列为空。Promise 微任务也是同理。整个流程:

1. 清空 nextTick 队列(递归)
2. 清空 Promise 微任务队列(递归)
3. 如果上述步骤产生了新的 nextTick 或微任务,回到 1
4. 全部清空后,进入下一个事件循环阶段

到这里,一切都很"标准",也符合绝大多数八股文的描述。


二、验证"标准答案"——CJS 模式

我们先在 CJS 模式下跑一下,证明八股文没有骗你。

创建一个 test-cjs.js(注意:不要在 package.json 里设置 "type": "module"):

// test-cjs.js(CommonJS 模式)
setImmediate(() => {
    console.log(1);
});

process.nextTick(() => {
    console.log(2);
    process.nextTick(() => {
        console.log(6);
    });
});

console.log(3);

Promise.resolve().then(() => {
    console.log(4);
    process.nextTick(() => {
        console.log(5);
    });
});

执行:

node test-cjs.js

输出:

3
2
6
4
5
1

完美,和八股文一模一样。我们来走一遍流程:

第一步:执行同步代码

  • setImmediate(cb) → 注册到 check 阶段队列
  • process.nextTick(cb) → 注册到 nextTick 队列(打印 2)
  • console.log(3)输出 3
  • Promise.resolve().then(cb) → 注册到微任务队列(打印 4)

此时队列状态:

nextTick 队列:[cb(2)]
微任务队列:  [cb(4)]
check 队列:  [cb(1)]

第二步:清空 nextTick 队列

  • 执行 cb(2)输出 2,并注册 nextTick(cb(6))
  • 队列没清空,继续 → 执行 cb(6)输出 6

第三步:清空微任务队列

  • 执行 cb(4)输出 4,并注册 nextTick(cb(5))
  • 微任务执行完毕,检查 nextTick → 执行 cb(5)输出 5

第四步:进入事件循环 check 阶段

  • 执行 setImmediate 回调 → 输出 1

最终:3 → 2 → 6 → 4 → 5 → 1


三、翻车现场——ESM 模式

现在,我们做一件"无害"的事情——在 package.json 里加上一行:

{
  "type": "module"
}

代码一个字都不改,再跑一次:

node index.js

输出:

3
4
2
5
6
1

4 跑到 2 前面去了!Promise 微任务比 nextTick 先执行了!

等等,不是说好了 nextTick > Promise 吗?

这并不是 Node.js 的 bug,这是 ESM 模块系统的执行机制 导致的必然结果。


四、为什么 ESM 会改变执行顺序?

这是本文的核心。要理解这个差异,必须搞清楚 CJS 和 ESM 在 Node.js 中的执行方式有什么本质不同。

4.1 CJS 的执行方式

在 CJS 模式下,Node.js 的执行流程大致是:

1. 同步加载模块(require 是同步的)
2. 同步执行模块代码
3. 模块代码执行完毕,进入事件循环
4. 事件循环开始前,先清空 nextTick 队列,再清空微任务队列

关键点:模块代码的执行是在 Node.js 的"主执行流"中完成的。执行完毕后,Node.js 通过自己的调度逻辑先处理 nextTick,再处理 Promise 微任务。

4.2 ESM 的执行方式

ESM 就完全不一样了。根据 ECMAScript 规范,ES Module 的加载和求值是 异步 的:

1. 解析模块依赖图(静态分析 import/export)
2. 异步加载所有模块
3. 按照依赖顺序对模块进行求值(evaluate)

关键来了:ESM 模块的求值(evaluate)过程本身就是在一个微任务(microtask)上下文中进行的。

这意味着什么?当你的 ESM 代码执行时,它已经处在 V8 引擎的微任务调度体系中了。代码执行完毕后:

  1. V8 引擎会先执行自己的微任务检查点(microtask checkpoint)
  2. Promise 微任务是 V8 原生管理的,所以会被 V8 先消费
  3. 然后控制权交还给 Node.js
  4. Node.js 再清空 nextTick 队列

用一张对比图来看:

CJS 执行完毕后的清空顺序:
┌─────────────────────────────────┐
│  Node.js 接管                    │
│  1. 清空 nextTick 队列            │  ← Node.js 自己的机制
│  2. 清空 Promise 微任务队列        │  ← V8 的微任务
│  3. 进入事件循环                   │
└─────────────────────────────────┘

ESM 执行完毕后的清空顺序:
┌─────────────────────────────────┐
│  V8 微任务检查点触发               │
│  1. 清空 Promise 微任务队列        │  ← V8 先动手了!
│  2. Node.js 接管                  │
│  3. 清空 nextTick 队列            │  ← Node.js 的 nextTick 被延后了
│  4. 进入事件循环                   │
└─────────────────────────────────┘

本质区别:在 CJS 中,Node.js 拥有微任务调度的主动权,所以它能让 nextTick 先走;而在 ESM 中,V8 引擎的微任务检查点先于 Node.js 的 nextTick 调度触发,所以 Promise 反而先执行了。

4.3 一句话总结

process.nextTick 是 Node.js 的"私货",不属于 ECMAScript 标准。在 ESM 模式下,V8 引擎遵循标准的微任务执行机制,自然不会优先照顾 Node.js 的私货。


五、用代码证明这不是玄学

为了彻底证实这个结论,我们做一组最简实验——只用 nextTickPromise 对比:

实验 1:CJS 模式

node -e "
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('promise'));
"

输出:

nextTick
promise

nextTick 先于 Promise

实验 2:ESM 模式

node --input-type=module -e "
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('promise'));
"

输出:

promise
nextTick

Promise 先于 nextTick

同样的代码,两行都没改,只是切换了模块系统,执行顺序就反过来了。

实验 3:在事件循环内部(两者一致)

// 无论 CJS 还是 ESM,事件循环内部的行为是一致的
setTimeout(() => {
    process.nextTick(() => console.log('nextTick'));
    Promise.resolve().then(() => console.log('promise'));
}, 0);

输出(两种模式都一样):

nextTick
promise

这说明:ESM 只影响顶层代码(Top-Level)的微任务执行顺序,一旦进入事件循环内部,nextTick 和 Promise 的优先级关系恢复正常。


六、回到那道面试题——ESM 下的完整解析

现在我们用 ESM 的规则重新分析原题:

setImmediate(() => {
    console.log(1);
});

process.nextTick(() => {
    console.log(2);
    process.nextTick(() => {
        console.log(6);
    });
});

console.log(3);

Promise.resolve().then(() => {
    console.log(4);
    process.nextTick(() => {
        console.log(5);
    });
});

第一步:执行同步代码

和 CJS 完全一样:

  • 注册 setImmediate(cb(1)) → check 队列
  • 注册 nextTick(cb(2)) → nextTick 队列
  • 输出 3
  • 注册 Promise.then(cb(4)) → 微任务队列

队列状态:

nextTick 队列:[cb(2)]
微任务队列:  [cb(4)]
check 队列:  [cb(1)]

第二步:V8 微任务检查点(ESM 的关键差异!)

因为是 ESM 模式,V8 的微任务检查点先触发:

  • 执行 cb(4)输出 4,注册 nextTick(cb(5))

此时队列状态:

nextTick 队列:[cb(2), cb(5)]
微任务队列:  [](已清空)
check 队列:  [cb(1)]

第三步:Node.js 清空 nextTick 队列

  • 执行 cb(2)输出 2,注册 nextTick(cb(6))
  • 执行 cb(5)输出 5
  • 执行 cb(6)输出 6

此时队列状态:

nextTick 队列:[](已清空)
微任务队列:  []
check 队列:  [cb(1)]

第四步:进入事件循环 check 阶段

  • 执行 setImmediate 回调 → 输出 1

最终:3 → 4 → 2 → 5 → 6 → 1


七、面试怎么答?

如果你在面试中遇到这道题,我建议分三层回答:

第一层:给出标准答案

在 CJS 模式下,输出顺序是 3 → 2 → 6 → 4 → 5 → 1。因为同步代码优先执行,然后 process.nextTick 队列会在 Promise 微任务之前被清空,最后才是 setImmediate 宏任务。

第二层:主动提出 ESM 的差异

但如果这段代码运行在 ESM 模式下("type": "module".mjs 文件),输出顺序会变成 3 → 4 → 2 → 5 → 6 → 1。因为 ESM 模块的求值本身处于 V8 的微任务上下文中,Promise 微任务会被 V8 引擎优先消费,先于 Node.js 的 nextTick 队列。

第三层:解释根本原因

这个差异的本质是 process.nextTick 是 Node.js 自己的调度机制,不属于 ECMAScript 标准。在 CJS 模式下,Node.js 对执行流有完全的控制权,可以让 nextTick 优先;但在 ESM 模式下,V8 引擎遵循标准的微任务执行机制,Node.js 的私有调度会被延后。不过这个差异只存在于顶层代码中,进入事件循环后两者行为一致。

这三层答出来,面试官绝对会对你刮目相看。


八、延伸思考

8.1 这算是 Node.js 的 Bug 吗?

不算。这是 CJS 和 ESM 两种模块系统的 设计差异 导致的必然结果。Node.js 官方文档中也有相关说明:

Microtask callbacks take priority over nextTick callbacks in this specific case because of V8's microtask checkpoint behavior during ES module evaluation.

8.2 process.nextTick 还值得用吗?

process.nextTick 在 Node.js 生态中仍然有其存在价值,比如:

  • 在事件循环内部,它的优先级依然高于 Promise
  • 用来确保回调在当前操作完成后、I/O 之前执行
  • 在流(Stream)和 EventEmitter 中广泛使用

但考虑到 ESM 正在成为 Node.js 的主流模块系统,你需要意识到 在顶层代码中,nextTick 的优先级不再是绝对的。如果你对执行顺序有严格要求,应该通过代码结构来保证,而不是依赖 nextTick 和 Promise 的微妙优先级差异。

8.3 queueMicrotask vs process.nextTick

还有一个相关的知识点:queueMicrotask() 是 Web 标准 API,Node.js 也支持。它创建的微任务和 Promise 处于同一级别。在 CJS 中,queueMicrotask 的回调在 nextTick 之后执行;在 ESM 中,它和 Promise 一样会先于 nextTick 执行。

process.nextTick(() => console.log('nextTick'));
queueMicrotask(() => console.log('queueMicrotask'));
Promise.resolve().then(() => console.log('promise'));

CJS 输出:nextTick → queueMicrotask → promise ESM 输出:queueMicrotask → promise → nextTick

8.4 面试中常见的相关题目

理解了上面的原理后,下面这些变种你也能轻松应对:

题目 1:async/await 的执行顺序

async function foo() {
    console.log(1);
    await Promise.resolve();
    console.log(2);
}

process.nextTick(() => console.log(3));
foo();
console.log(4);

CJS 下:1 → 4 → 3 → 2 ESM 下:1 → 4 → 2 → 3

原理相同:await 后面的代码本质上就是 Promise.then,在 ESM 中会先于 nextTick 执行。

题目 2:setTimeout vs setImmediate

setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));

这道题的输出顺序是 不确定的(取决于系统调度),但如果放在 I/O 回调中,setImmediate 一定先于 setTimeout

const fs = require('fs');
fs.readFile(__filename, () => {
    setTimeout(() => console.log('timeout'), 0);
    setImmediate(() => console.log('immediate'));
});
// 始终输出:immediate → timeout

九、总结

维度 CJS ESM
模块加载 同步 (require) 异步 (import)
顶层代码执行上下文 Node.js 主执行流 V8 微任务上下文
顶层 nextTick vs Promise nextTick 优先 Promise 优先
事件循环内部 nextTick vs Promise nextTick 优先 nextTick 优先
"type" 设置 默认 / "commonjs" "module"
文件扩展名 .js / .cjs .js(需配置) / .mjs

一句话记忆:CJS 中 Node.js 说了算,nextTick 是大哥;ESM 中 V8 说了算,Promise 是大哥。但进了事件循环,nextTick 依然是大哥。


写在最后

这个坑之所以"阴间",是因为:

  1. 代码完全一样,只是 package.json 多了一行 "type": "module"
  2. 绝大多数八股文和面试题都没有区分模块系统来讨论
  3. 现在的新项目基本都用 ESM 了,所以你跑出来的结果大概率和背的不一样

下次面试官再问事件循环,别忘了反问一句:"请问这段代码是跑在 CJS 还是 ESM 下?"

如果面试官愣住了——恭喜你,你已经赢了。


如果这篇文章帮到了你,欢迎点赞收藏,关注我获取更多 Node.js 深水区技术分享。

移动端调试工具VConsole初始化时的加载阻塞问题

问题复现(场景:小程序应用)

将VConsole下载到本地进行,在main.js中进行初始化,当上线后,页面首次加载会出现卡顿空白页,刷新之后显示正常


import Vue from 'vue'

import App from './App.vue'

import router from './router'

import ElementUI from 'element-ui'

import 'element-ui/lib/theme-chalk/index.css'

import VConsole from 'vconsole'

new VConsole();

new Vue({

render: h => h(App),

router,

}).$mount('#app')

阻塞原因

首次加载无缓存 + VConsole 初始化抢占主线程,阻塞了页面渲染;第二次刷新有缓存,VConsole 加载 / 初始化耗时骤降,不再阻塞

「第一次卡顿白屏」

当在 main.js 里直接 new VConsole() 时,会发生下面过程:

  • ✅ 资源无缓存:第一次进页面,浏览器要从网络下载 vconsole.js 源码(约 150KB),占网络带宽,拖慢页面 JS 加载;
  • ✅ 抢占主线程:VConsole 初始化时,会做「重写 console、插 DOM 面板、监听网络请求」等操作,这些操作和 Vue 初始化、页面 DOM 渲染抢同一根「主线程」,浏览器顾此失彼,页面来不及渲染就白屏;
  • ✅ 时序冲突:VConsole 初始化和 Vue 挂载($mount('#app'))同时执行,甚至更早,直接打乱页面渲染节奏,导致首屏出不来。

2.「第二次就正常」

第二次进页面时:

  • ✅ 资源有缓存:浏览器已经把 vconsole.js 存在本地,不用再从网络下载,加载耗时从几百毫秒降到几毫秒;
  • ✅ 初始化变快:VConsole 初始化的核心资源(如样式、面板模板)都在缓存里,主线程占用时间大幅减少,不会再阻塞 Vue 渲染和页面显示。

即使下载依赖到本地,只要在main.js立刻初始化同样会造成阻塞


加延迟让vue先完初始化,console再进行加载

加延迟解决卡顿

import { createApp } from 'vue'

import App from './App.vue'

// 1. 先挂载 Vue 应用

const app = createApp(App)

app.mount('#app')

// 2. 延迟初始化 VConsole(仅开发环境)

if (process.env.NODE_ENV === 'development') {

setTimeout(() => {

import('vconsole').then(({ default: VConsole }) => {

new VConsole()

})

}, 100)

}

虽然加延迟能解决卡顿,但是看不到初始化运行时加载的其他js文件

利用async和preload解决卡顿问题,但是会丢失少部分初始化最开始的加载日志文件

比如这样:、

<head>

<!-- VConsole异步加载 -->

<script src="https://cdn.bootcdn.net/ajax/libs/vConsole/3.15.0/vconsole.min.js" async></script>

<!-- 项目JS正常引入(也可加async) -->

<script src="./js/chunk-vendors.js"></script>

<script src="./js/app.js"></script>

<script>

// 等页面所有资源加载完,再初始化VConsole(避免卡顿)

window.addEventListener('load', () => {

if (window.VConsole) { // 检查VConsole是否加载完成

new window.VConsole({ disableLogScrolling: true });

console.log('VConsole初始化完成(async方案)');

// 此时执行你的日志打印逻辑

window.printNewJS();

}

});

// 你的printNewJS等逻辑保留

window.printedJS = new Set();

window.printNewJS = function() 

</script>

</head>

终极解决方案:先完成console的初始化再引入打包配的.js文件

<head>

<!-- 预加载VConsole,加速下载 -->

<link rel="preload" href="https://cdn.bootcdn.net/ajax/libs/vConsole/3.15.0/vconsole.min.js" as="script">

<script>

// 先加载并初始化VConsole

const scriptVConsole = document.createElement('script');

scriptVConsole.src = "https://cdn.bootcdn.net/ajax/libs/vConsole/3.15.0/vconsole.min.js";

scriptVConsole.onload = () => {

new VConsole();

console.log('VConsole初始化完成');

// VConsole就绪后,动态引入项目JS(保留日志)

loadProjectJS();

};
document.head.appendChild(scriptVConsole);

// 动态引入项目JS(原逻辑)

function loadProjectJS() {

const projectJS = ['./js/chunk-vendors.js', './js/app.js'];

projectJS.forEach(path => {

const script = document.createElement('script');

script.src = path;

document.body.appendChild(script);

});

}

</script>

</head>

但是这样有一个缺点:会有一个短暂的空白,所以可以给idnex.html中加入一个loading提示过渡,这样就可以查看项目运行过程中整个所有加载的文件运行状态结果

【Flutter×鸿蒙】FVM 不认鸿蒙 SDK?4步手动塞进去

系列导航:

我第一次让 FVM 管理鸿蒙版 Flutter SDK 时,前后踩了 4 个坑,花了大半天才跑通。事后复盘发现,每个坑都不难,只是没人提前告诉我"为什么要这样做"。这篇把整个过程拆成 5 关,每关讲清「为什么」和「怎么做」,争取让你 20 分钟一次通关。

前置条件:请先完成第一篇的全部内容——DevEco Studio 已安装,ohpm、node、hvigorw 在终端里都能正常调用。


🗺️ 通关路线图

关卡 任务 预计耗时
第1关 安装 FVM 2 min
第2关 克隆鸿蒙版 SDK 5 min(取决于网速)
第3关 修复版本"身份证" 3 min
第4关 指定鸿蒙 SDK 路径 1 min
第5关 全绿验证 2 min

🎯 第 1 关:安装 FVM

目标

让终端认识 fvm 命令。

为什么需要 FVM

一句话——让不同项目用不同版本的 Flutter,互不干扰。比如项目 A 用官方 3.24 跑 Android/iOS,项目 B 用鸿蒙版 3.35.8。FVM 就是 Flutter 的"版本档案柜",每个抽屉放一个版本。

📋 操作

# macOS(在终端里执行,这是用 Homebrew 包管理器安装 FVM)
brew install fvm
# Windows(在 cmd 或 PowerShell 中执行,这是用 Chocolatey 包管理器安装 FVM)
choco install fvm

安装完后,配置 FVM 缓存路径。把以下两行写入 ~/.zshrc(上一篇介绍过,这是 Mac 终端的配置文件):

# FVM 存放所有 Flutter 版本的目录
export FVM_CACHE_PATH=$HOME/fvm
# 让 FVM 的默认版本可以直接用 flutter 命令调用
export PATH="$HOME/fvm/default/bin:$PATH"

保存后执行下面这条命令,让刚才的配置立即生效(否则要关掉终端重新打开):

source ~/.zshrc

✅ 验证

# 查看 FVM 版本号,确认安装成功
fvm --version

看到版本号(如 3.1.4)就过关了。

⚠️ 如果报 command not found:Mac 用户确认已安装 Homebrew(执行 brew --version 看有没有输出);Windows 用户确认已安装 Chocolatey(执行 choco --version)。如果包管理器本身都没装,请先去官网安装。


🎯 第 2 关:克隆鸿蒙版 SDK

目标

把华为的鸿蒙版 Flutter 放进 FVM 管辖。

为什么不能直接 fvm install

正常装 Flutter 只需要 fvm install 3.24.0,FVM 会自动去 GitHub 下载。但鸿蒙版是华为团队在 AtomGit(国内代码托管平台)上单独维护的,FVM 的世界里它根本不存在。所以我们要"手动入库"——自己下载代码,放到 FVM 的档案柜里,假装它一直在那。

⚠️ 本关最大的坑:分支名和版本号是两回事!

仓库的分支叫 oh-3.35.7-dev,看到 3.35.7 你会以为版本就是 3.35.7。但实际上代码里的版本已经迭代到了 3.35.8-ohos-0.0.2

类比:Git 分支叫 feature/login-v1,但代码早就改到 v3 了。分支名是创建时起的,不会跟着版本号自动更新。

千万别拿分支名当版本号用,团队必须统一用 3.35.8-ohos-0.0.2 这个真实版本号。

📋 操作

# --depth 1 只取最新代码,省空间(省去几个 GB 的历史记录)
git clone -b oh-3.35.7-dev --depth 1 
https://atomgit.com/openharmony-tpc/flutter_flutter.git 
~/fvm/versions/3.35.8-ohos-0.0.2

注意看:clone 命令里分支名是 oh-3.35.7-dev,但目标文件夹名是 3.35.8-ohos-0.0.2——这不是写错了,上面已经解释了为什么不一样。

💡 怎么确认真实版本号? clone 完后执行 ~/fvm/versions/3.35.8-ohos-0.0.2/bin/flutter --version 看输出。如果加入已有团队,直接看项目的 .fvmrc 文件(命令:cat .fvmrc)。

✅ 验证

# 确认文件下载成功(ls = 列出目录内容)
ls ~/fvm/versions/3.35.8-ohos-0.0.2/bin/flutter

文件存在就过关。

⚠️ 如果报 No such file or directory:回去检查 clone 命令是否执行成功。常见原因是网络超时(AtomGit 在国内,通常不需要梯子,但公司内网可能有限制)。重新执行 clone 前,先删掉残留目录:rm -rf ~/fvm/versions/3.35.8-ohos-0.0.2,再重试。


🎯 第 3 关:修复版本"身份证"

目标

让 FVM 正确识别这个 SDK 的版本号。

为什么要做这步

clone 下来的 SDK 有两张"证件":

  1. version 文件——相当于身份证,一行文本写着版本号
  2. bin/cache/flutter.version.json——相当于内部档案,JSON 格式的详细版本信息

问题是,这两张证件上都写着 0.0.0-unknown(因为鸿蒙团队是从开发分支构建的,没有打标准标签)。但我们的文件夹名叫 3.35.8-ohos-0.0.2。FVM 一查——名字对不上,直接翻脸。

⚠️ 不做这步的后果:FVM 会弹出 "Version mismatch" 并试图删掉你的 SDK 重装。如果看到了这个弹窗,千万不要选任何选项,按 Ctrl+C(Mac 也是 Ctrl 不是 Cmd)退出,回来做这步。

📋 操作

macOS / Linux:

cd ~/fvm/versions/3.35.8-ohos-0.0.2
# 第一步:改"身份证"
echo -n "3.35.8-ohos-0.0.2" > version
# 第二步:初始化 Flutter 引擎(首次运行会下载 Dart SDK,需要等 1-3 分钟)
bin/flutter --version
# 第三步:改"内部档案"(把所有 0.0.0-unknown 替换成正确的版本号)
sed -i '' 's/0.0.0-unknown/3.35.8-ohos-0.0.2/g' bin/cache/flutter.version.json

Windows PowerShell:

# 进入 SDK 所在目录
cd $env:USERPROFILE\fvm\versions\3.35.8-ohos-0.0.2
# 第一步:改"身份证"
"3.35.8-ohos-0.0.2" | Set-Content version -NoNewline
# 第二步:初始化引擎
bin\flutter --version
# 第三步:改"内部档案"(PowerShell 的查找替换写法)
(Get-Content bin\cache\flutter.version.json) -replace '0.0.0-unknown', '3.35.8-ohos-0.0.2' | Set-Content bin\cache\flutter.version.json

⚠️ 三步的顺序不能乱——第二步会生成 flutter.version.json 文件,第三步才有东西可改。如果你先执行了第三步,会报文件不存在。

✅ 验证

# 回到任意目录都可以执行(fvm list = 列出 FVM 管理的所有 Flutter 版本)
fvm list

看到 Version 列显示 3.35.8-ohos-0.0.2(不是空白、不是 Need setup、不是 0.0.0-unknown),这关就过了。

02_fvm_list.png ⚠️ 如果还是显示异常,逐一排查两张"证件":

# 检查"身份证"内容
cat ~/fvm/versions/3.35.8-ohos-0.0.2/version
# 应该输出:3.35.8-ohos-0.0.2(没有多余空行)

# 检查"内部档案"有没有残留的 0.0.0-unknown
cat ~/fvm/versions/3.35.8-ohos-0.0.2/bin/cache/flutter.version.json
# 里面所有 version 字段应该都是 3.35.8-ohos-0.0.2

如果 version 文件内容不对,重新执行第一步;如果 JSON 里还有 0.0.0-unknown,重新执行第三步。


🎯 第 4 关:指定鸿蒙 SDK 路径

目标

让 Flutter 知道鸿蒙的 SDK(OpenHarmony SDK)装在哪。

为什么不用环境变量

我试过 HOS_SDK_HOMEOHOS_SDK_HOME 等环境变量,时灵时不灵。原因是不同方式打开的终端(VS Code 内置终端 vs 系统终端 vs CI 环境)加载配置文件的顺序不一样,变量可能没被读到。flutter config 会把路径写入 Flutter 自己的配置文件,不管从哪里启动都能读到,最稳。

📋 操作

# 把鸿蒙 SDK 的位置"写死"到 Flutter 的配置里(一次性操作)
~/fvm/versions/3.35.8-ohos-0.0.2/bin/flutter config \
--ohos-sdk="/Applications/DevEco-Studio.app/Contents/sdk"

⚠️ Windows 用户路径改为:--ohos-sdk="C:\Program Files\Huawei\DevEco Studio\sdk"

请根据 DevEco Studio 实际安装路径调整。不确定装在哪?打开 DevEco Studio → Settings → SDK 页面可以看到路径。

终端输出 Setting "ohos-sdk" value to "..." 就成功了。

✅ 验证

不急,下一关一起验收。


🎯 第 5 关:全绿验证

目标

flutter doctor 中 HarmonyOS toolchain 一栏显示绿色对勾。

📋 操作

# 运行 Flutter 的环境诊断工具(-v 表示显示详细信息)
~/fvm/versions/3.35.8-ohos-0.0.2/bin/flutter doctor -v

✅ 验证

关注输出中的 HarmonyOS 那一栏:


[✓] HarmonyOS toolchain - develop for HarmonyOS devices
• OpenHarmony Sdk at /Applications/DevEco-Studio.app/Contents/sdk,
available api versions has [22:default]
• Ohpm version 6.0.1
• Node version v18.20.1
• Hvigorw binary at .../hvigor/bin/hvigorw

看到 [✓] 加上 4 个子项都有值 = 通关!

💡 你可能会看到 Flutter 那栏有几个 ! 警告(channel 不标准、upstream 不是官方地址)。这是鸿蒙版的正常现象,完全不影响开发和打包,放心忽略。

⚠️ 如果 HarmonyOS 那栏还是红叉,按优先级排查:

  1. SDK not found → 回第 4 关检查 config 路径是否正确
  2. ohpm/hvigorw missing → 回第一篇检查环境变量
  3. Version mismatch → 回第 3 关检查两张"证件"

🔧 附加关:FVM 的"碎碎念"

通关后你会发现,每次用 fvm flutter xxx 时 FVM 都会弹 "not a valid version" 的警告让你确认。这不是报错,只是 FVM 在说:"这个版本号我在官方列表里查不到,你确定要用吗?"

三种应对方式:

  1. 手动按 y——每次弹出输入 y 回车
  2. 自动确认——命令前加 yes |
yes | fvm flutter doctor
  1. 绕过 FVM——直接用绝对路径调用,完全不弹警告:
~/fvm/versions/3.35.8-ohos-0.0.2/bin/flutter doctor

我推荐第三种,路径虽长但最省心。可以设个快捷方式(alias)缩短它:

# 把这行加到 ~/.zshrc 里(alias = 给一条长命令起个短名字)
alias hflutter="$HOME/fvm/versions/3.35.8-ohos-0.0.2/bin/flutter"

保存后 source ~/.zshrc,之后直接 hflutter runhflutter doctor 就行。


🏆 通关总结

项目 状态
FVM ✅ 已安装
鸿蒙版 Flutter SDK ✅ ~/fvm/versions/3.35.8-ohos-0.0.2
version 文件 ✅ 已修复
flutter.version.json ✅ 已修复
flutter config --ohos-sdk ✅ 已配置
flutter doctor HarmonyOS ✅ 全绿

回顾核心逻辑:FVM 只管官方 Flutter,鸿蒙版要我们手动塞进去(第 2 关);塞进去后"证件"信息对不上,需要手动修正(第 3 关);最后告诉 Flutter 鸿蒙 SDK 在哪(第 4 关)。理解了这条线,以后鸿蒙版 SDK 升级换版本号,你也能照样搞定。

如果中途卡住,大概率是版本号写错了——检查文件夹名、version 文件内容、flutter.version.json 里的版本号,三者必须完全一致


下一篇预告:SDK 准备好了,接下来要把你的老 Flutter 项目跑到鸿蒙上——听起来就是敲几行命令的事?没那么简单。→ Flutter 鸿蒙通关手册(三):项目搬家

iOS PDF阅读器段评实现:如何从 PDFSelection 精准还原一个自然段

iOS PDF 阅读器段评实现:如何从 PDFSelection 精准还原一个自然段

目标读者:有 PDFKit 使用经验的 iOS 开发者。
本文重点:几何分块算法、段落识别逻辑、跨栏语义合并三个核心难点。


背景:段评是什么,难在哪里

杂志类 App 有一个常见需求——用户长按某段正文,划出一段话,然后对这段话写评论。这个交互在微信读书、Kindle 里都很成熟,但它们针对的是结构化的电子书格式(ePub、MOBI),正文结构天然清晰。

PDF 没有这种结构。一份杂志 PDF 在底层只有一堆带坐标的"文字片段"(glyph run),没有段落、没有栏、没有语义层次。PDFKit 提供的 PDFSelectionselectionsByLine 能给你"行",但它不知道哪些行属于同一个段落,也不知道这一页有几栏。

因此,段评的核心问题是:给定用户选中的一行文字,如何还原它所在的完整自然段?

这个问题比想象中复杂,主要难点有三个:

  1. 几何噪声:PDF 的行坐标存在浮点误差,标题、页码、图注混杂其中,必须过滤。
  2. 多栏布局:杂志常见双栏、三栏排版,阅读顺序不是简单地从上到下。
  3. 跨栏断段:一个自然段可能从左栏末尾延续到右栏开头,PDFKit 对此一无所知。

XLPDFParagraphEngine 的设计思路,就是用纯几何方法逐层解决这三个问题。


整体架构:四层流水线

整个引擎的入口是:

/// 自定义PDFView里面获取menu
+ (NSString *)paragraphTextFromSelection:(PDFSelection *)selection
                                document:(PDFDocument *)document;

它的内部执行路径是一条清晰的四层流水线:

PDFSelection
    │
    ▼
① buildLinesFromSelection     — 行提取 + 噪声过滤
    │
    ▼
② buildBlocksFromLinesIteratively  — 几何连通分块
    │
    ▼
③ readingOrderForBlock         — 列识别 + 段落切分
    │
    ▼
④ mergeSemanticContinuousBlocks — 跨栏语义合并
    │
    ▼
paragraphTextFromLines         — 拼接文本输出

每一层解决一个独立问题,下面逐层展开。


第一层:行提取与噪声过滤

PDFKit 的 selectionsByLine 会把选区内的每一行作为独立的 PDFSelection 返回,这是我们的原始数据源。但原始数据有大量噪声需要清理。

/// 获取所有lines
+ (NSArray<XLPDFLine *> *)buildLinesFromPage:(PDFPage *)page document:(PDFDocument *)document {
    CGRect pageRect = [page boundsForBox:kPDFDisplayBoxMediaBox];
    PDFSelection *pageSelection = [page selectionForRect:pageRect];
    return [self buildLinesFromBaseSelection:pageSelection document:document];
}

/// 获取选中的lines
+ (NSArray<XLPDFLine *> *)buildLinesFromSelection:(PDFSelection *)selection document:(PDFDocument *)document {
    return [self buildLinesFromBaseSelection:selection document:document];
}

+ (NSArray<XLPDFLine *> *)buildLinesFromBaseSelection:(PDFSelection *)baseSelection document:(PDFDocument *)document {

    NSMutableArray<XLPDFLine *> *lines = [NSMutableArray array];

    NSArray<PDFPage *> *pages = baseSelection.pages;

    for (PDFSelection sel in baseSelection.selectionsByLine) {

        NSString *text = sel.string;
        if (text.length == 0) continue;

        // 找到当前行所属 page
        PDFPage *linePage = nil;
        CGRect rect = CGRectZero;

        for (PDFPage *page in pages) {
            rect = [sel boundsForPage:page];
            if (!CGRectIsEmpty(rect)) {
                linePage = page;
                break;
            }
        }

        if (!linePage) continue;

        if (CGRectIsEmpty(rect)) continue;

        // ========= 公共过滤逻辑 =========

        CGFloat width = CGRectGetWidth(rect);

        CGFloat height = CGRectGetHeight(rect);

        // 过滤竖排
        if (text.length > 1 && height > width * 2.0) continue;
        // 过滤异常高度
        CGRect pageRect = [linePage boundsForBox:kPDFDisplayBoxMediaBox];
        CGFloat pageHeight = CGRectGetHeight(pageRect);
        if (height > pageHeight * 0.05) continue;

  
        NSString *trimText = [text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];

        if (trimText.length == 0) continue;

        // 过滤纯数字编号(01、02、1、2、一、二 等页码/序号)
        NSString *numberPattern = @"^\\s*[零一二三四五六七八九十百\\d]+[、.]?\\s*$";
        NSPredicate *numberPredicate = [NSPredicate predicateWithFormat:@"SELF MATCHES %@", numberPattern];
        if ([numberPredicate evaluateWithObject:trimText]) continue;

        // ========= 构建模型 =========
        XLPDFLine *line = [XLPDFLine new];
        line.selection = sel;
        line.page = linePage;
        line.rect = rect;
        line.text = trimText;

        NSInteger pageIndex = [document indexForPage:linePage];
        line.pageIndex = pageIndex == NSNotFound ? -1 : pageIndex;

        [lines addObject:line];
    }

    return lines;
}

这里的过滤策略针对杂志 PDF 的典型噪声:

  • 竖排文字:部分杂志有竖向装饰文字,height > width * 2.0 可以有效识别并排除。
  • 异常高度:正文行高通常不超过页面高度的 5%,超出这个比例的往往是大图、横幅或装饰元素。
  • 页码与序号:正则 ^[零一二三四五六七八九十百\d]+[、.]?$ 可以匹配中英文页码和列表编号,避免它们干扰后续分段判断。

过滤完成后,每一行被封装成 XLPDFLine 模型,携带 textrectpagepageIndex 等属性,供后续层使用。


第二层:几何连通分块

拿到干净的行列表后,下一个问题是:这一页上有几个独立的文字区域?

杂志版式复杂,一页上可能同时存在主正文区、侧边栏、图注、引言框等多个互不相连的文字区域。如果不先区分这些区域,段落识别就会跨区混淆。

引擎使用了一个经典的**几何连通图(Connected Components)**算法:

将每一行的 rect 向外膨胀(inflate)半个行高
如果两行膨胀后的 rect 有交叉 → 认为它们"连通"
对所有行做图的连通分量遍历 → 每个连通分量就是一个 Block

膨胀量选择行高的 50%,是一个关键的经验值设定:

+ (BOOL)linesConnected:(XLPDFLine *)a other:(XLPDFLine *)b {
    CGFloat insetA = a.rect.size.height * 0.5;
    CGFloat insetB = b.rect.size.height * 0.5;
    CGRect ra = CGRectInset(a.rect, -insetA, -insetA);
    CGRect rb = CGRectInset(b.rect, -insetB, -insetB);
    return CGRectIntersectsRect(ra, rb);
}

为什么不用固定像素值?因为杂志里的字号差异很大——正文可能是 10pt,大标题可能是 36pt。固定像素膨胀会导致小字号的脚注与正文粘连,或者大标题与相邻栏文字误连。用行高比例膨胀,让每行的"感知范围"与自身字号成正比,鲁棒性更好。

连通分量的遍历使用迭代 DFS:

+ (NSArray *)buildBlocksFromLinesIteratively:(NSArray *)lines {
    NSMutableArray *remaining = [lines mutableCopy];
    NSMutableArray *resultBlocks = [NSMutableArray array];

    while (remaining.count > 0) {
        NSArray *block = [self buildSingleBlockFromLines:remaining];
        [resultBlocks addObject:block];
        [remaining removeObjectsInArray:block];
    }
    return resultBlocks;
}

每次从剩余行中任取一行作为起点,BFS/DFS 扩展出整个连通分量,然后从剩余集合中移除,直到所有行都被分配完毕。


第三层:阅读顺序还原与段落切分

每个 Block 内部可能还有多列(例如一个双栏正文区,在几何上是一个连通分量)。这一层先识别列,再在每列内部切分段落。

3.1 列识别:X 轴区间合并

+ (NSArray<XLPDFLine *> *)readingOrderForBlock:(NSArray<XLPDFLine *> *)block {
 
    NSArray *ranges = [self xRangesFromBlock:block]; // 投影到X轴:x+w
    NSArray *columnRanges = [self mergeXRanges:ranges]; // 算出有多少列
    NSArray *columns = [self splitBlock:block intoColumns:columnRanges]; // 划入列里
    NSMutableArray *result = [NSMutableArray array];
    NSInteger paragraphIndex = 0;

    // 列里面直接按照Y排序即可
    for (NSArray<XLPDFLine *> *column in columns) {
        // 分段
        NSArray *ordered = [self readingOrderForColumnByIndentOnly:column paragraphStartIndex:&paragraphIndex];
        [result addObjectsFromArray:ordered];
    }

    return result;
}

具体做法:把 Block 内每一行的 [minX, maxX] 区间收集起来,按 minX 排序后做区间合并(sweep line),相互重叠或相接的区间合并为一个列边界。最终得到若干互不重叠的列区间,每一行按其中心 X 坐标归入对应的列。

这个方法的优势是完全不依赖任何先验知识,无论一页有几栏、栏宽是否均等,都能正确识别。

3.2 段落切分:三条几何规则

列识别完成后,列内的行按 Y 坐标从上到下排好序。接下来要判断相邻两行是否属于同一段落,引擎使用了三条互补的规则:

+ (NSArray<XLPDFLine *> *)readingOrderForColumnByIndentOnly:(NSArray<XLPDFLine *> *)column paragraphStartIndex:(NSInteger *)paragraphIndex {

    NSArray<XLPDFLine *> *sorted = [self sortLinesByYDescending:column];
    CGFloat baseMinX = [self baseMinXForColumn:sorted];
    CGFloat baseMaxX = [self baseMaxXForColumn:sorted];
    CGFloat columnWidth = baseMaxX - baseMinX;

    [sorted enumerateObjectsUsingBlock:^(XLPDFLine * _Nonnull line, NSUInteger idx, BOOL * _Nonnull stop) {

        if (idx > 0) {

            XLPDFLine *prevLine = sorted[idx - 1];

            // 条件1:当前行首行缩进
            CGFloat indent = CGRectGetMinX(line.rect) - baseMinX;
            BOOL currentLineIsHead = indent > 10.0;

            // 条件2:上一行是尾行(右侧留白超过列宽 10%)
            CGFloat prevLineTrailingGap = baseMaxX - CGRectGetMaxX(prevLine.rect);
            BOOL prevLineIsTail = prevLineTrailingGap > columnWidth * 0.1;

            // 条件3:行间距超过行高阈值
            CGFloat gap = CGRectGetMinY(prevLine.rect) - CGRectGetMaxY(line.rect);
            CGFloat lineHeight = CGRectGetHeight(line.rect);
            BOOL hasLargeGap = gap > lineHeight * 0.8;

            if (currentLineIsHead || prevLineIsTail || hasLargeGap) {
                (*paragraphIndex)++;
            }
        }

        line.paragraphIndex = *paragraphIndex;
    }];

    return sorted;

}

规则1(首行缩进) 是中文排版最常见的段落标记,10pt 的阈值约等于一个汉字的宽度。

规则2(末行留白) 是规则1的补充:段落末行通常不会写满整行。15% 的阈值过滤掉因行尾标点导致的微小留白,同时能识别出明显的短尾行。注意这里使用的 baseMaxX 是列内所有行 maxX 的中位数,而不是最大值,这样对行尾有标点突出的情况更鲁棒。

规则3(行间距) 用于处理无缩进、无留白但通过空行分隔的段落风格(英文排版常见)。

三条规则取 OR,任意一条满足就认为新段落开始,paragraphIndex 递增。


第四层:跨栏语义合并(最难的部分)

前三层解决了单个 Block 内部的问题,但杂志双栏排版有一个特殊情况:

一个自然段从左栏末尾开始,写满后在右栏顶部继续。

这两部分在几何上属于不同的 Block(左栏和右栏不连通),但语义上是同一个段落。这就是跨栏语义合并问题。

4.1 判断标准:段尾 + 段首

合并的充要条件是:左栏某 Block 的最后一行是段尾行(Tail) ,同时右栏某 Block 的第一行是段首行(Head) ,且两者字号一致。

段尾判断:末行写满(右侧留白 < 列宽10%)且没有句末标点(。!?;…等):

+ (BOOL)isTailBlock:(NSArray *)block {
    XLPDFLine *lastLine = block.lastObject;
    CGFloat trailingGap = columnMaxX - CGRectGetMaxX(lastLine.rect);
    BOOL noTrailingGap  = trailingGap <= columnWidth * 0.1;
    BOOL noEndingSymbol = ![self lineEndsWithParagraphSymbol:lastLine];
    return noTrailingGap && noEndingSymbol;
}

段尾判断:判断一行是否以句末标点结尾(处理引号包裹和英文小数)

+ (BOOL)lineEndsWithParagraphSymbol:(XLPDFLine *)line {

    NSCharacterSet *endingSymbols = [NSCharacterSet characterSetWithCharactersInString:@"。!?;.!?;…"];
    NSCharacterSet *wrapperSet =
    [NSCharacterSet characterSetWithCharactersInString:@"”’\"'))】》〉 "];
    NSString *trimmed = [line.text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];

    if (trimmed.length == 0) return NO;

    NSInteger index = (NSInteger)trimmed.length - 1;

    // 跳过包裹字符
    while (index >= 0 &&
           [wrapperSet characterIsMember:[trimmed characterAtIndex:index]]) {
        index--;
    }

    if (index < 0) return NO;

    unichar c = [trimmed characterAtIndex:index];

    // 英文小数点不算句末(3.14)
    if (c == '.' && index > 0 &&
        [[NSCharacterSet decimalDigitCharacterSet]
         characterIsMember:[trimmed characterAtIndex:index - 1]]) {
        return NO;
    }

  
    return [endingSymbols characterIsMember:c];
}

段首判断:首行无明显缩进(缩进量 ≤ 10pt),说明这一行是接续上一栏的内容,而不是新段落的起点:

+ (BOOL)isHeadBlock:(NSArray *)block {
    XLPDFLine *firstLine = block.firstObject;
    CGFloat indent = CGRectGetMinX(firstLine.rect) - columnMinX;
    return indent <= 10.0;
}

解决多栏PDF的跨列语义连续问题

/// 先对所有allPageLines进行分列(大概2、3列)
/// 将所有block归列,每一列N个block
/// 每一列的从后往前找可能是段尾的block
/// 从第二列内部blocks遍历从前往后判断是否是段首
/// 合并段位和段首,处理blockIndex、paragraphIndex
+ (NSArray<NSArray<XLPDFLine *> *> *)mergeSemanticContinuousBlocks:(NSArray<NSArray<XLPDFLine *> *> *)blocks pageLines:(NSArray<XLPDFLine *> *)allPageLines {

    if (blocks.count < 2) return blocks;

    NSArray<NSValue *> *columnRanges = [self detectColumnRanges:allPageLines];

    if (columnRanges.count < 2) return blocks;

    // 按列分组(保持列内Y排序)
    NSMutableArray<NSMutableArray *> *columns = [NSMutableArray array];
    for (NSInteger i = 0; i < columnRanges.count; i++) {
        [columns addObject:[NSMutableArray array]];
    }

    for (NSArray<XLPDFLine *> *block in blocks) {

        NSInteger colIdx = [self columnIndexForBlock:block inRanges:columnRanges];
        if (colIdx >= 0) [columns[colIdx] addObject:[block mutableCopy]];
    }

    for (NSMutableArray *column in columns) {
        [column sortUsingComparator:^NSComparisonResult(NSArray *a, NSArray *b) {

            CGFloat maxYA = 0, maxYB = 0;
            for (XLPDFLine *l in a) maxYA = MAX(maxYA, CGRectGetMaxY(l.rect));
            for (XLPDFLine *l in b) maxYB = MAX(maxYB, CGRectGetMaxY(l.rect));
            return maxYA > maxYB ? NSOrderedAscending : NSOrderedDescending;
        }];
    }

    // 跨列合并
    for (NSInteger col = 0; col < (NSInteger)columns.count - 1; col++) {

        NSMutableArray *currentCol = columns[col];
        NSMutableArray *nextCol    = columns[col + 1];
        if (currentCol.count == 0 || nextCol.count == 0) continue;

        CGFloat dominantLineHeight = [self dominantLineHeightInColumn:currentCol];
        NSMutableArray<XLPDFLine *> *tailBlock = nil;

        for (NSInteger blockIdx = (NSInteger)currentCol.count - 1; blockIdx >= 0; blockIdx--) {
            // 特别注意要倒叙,然后过滤飞主体文本block
            NSMutableArray<XLPDFLine *> *block = currentCol[blockIdx];
            if ([self lineHeightMatches:block withHeight:dominantLineHeight] &&
                [self isTailBlock:block]) {
                tailBlock = block;
                break;
            }
        }

        if (!tailBlock) continue;

        NSInteger searchCol = col + 1;
        while (searchCol < (NSInteger)columns.count) {

            NSMutableArray *searchNextCol = columns[searchCol];
            if (searchNextCol.count == 0) {
                searchCol++;
                continue;
            }

            NSArray<XLPDFLine *> *headBlock = nil;
            NSInteger headIdx = -1;
            for (NSInteger i = 0; i < (NSInteger)searchNextCol.count; i++) {
                NSArray<XLPDFLine *> *block = searchNextCol[i];
                if ([self isHeadBlock:block] &&
                    [self blockContainsParagraphEndingSymbol:block] &&
                    [self lineHeightMatches:tailBlock with:block]) {
                    headBlock = block;
                    headIdx   = i;
                    break;
                }
            }

            if (!headBlock) break;

            [self mergeBlock:headBlock intoBlock:tailBlock];
            [searchNextCol removeObjectAtIndex:headIdx];

            if (![self isTailBlock:tailBlock]) break;

            searchCol++;
        }
    }

    // 重整blockIndex + 构建结果数组
    NSMutableArray<NSArray<XLPDFLine *> *> *result = [NSMutableArray array];
    NSInteger idx = 0;
    for (NSMutableArray *column in columns) {
        for (NSArray<XLPDFLine *> *block in column) {
            for (XLPDFLine *line in block) line.blockIndex = idx;
            idx++;
            if ([self blockContainsParagraphEndingSymbol:block] || block.count > 6) {
                [result addObject:block];
            }
        }
    }

    return [result copy];
}

4.2 列检测:中心 X 聚类

跨栏合并需要先知道页面有几列,以及每列的 X 边界。引擎用了一个轻量的聚类方法:

// 收集所有行的中心X,排序后按间隙聚类
// 相邻 centerX 差值超过页宽的 10% → 认为是列间距
CGFloat gapThreshold = CGRectGetWidth(pageRect) * 0.10;

通过这个间隙阈值,可以把所有行的中心 X 分成若干簇,每簇的 [minX, maxX] 加上半行高的 padding 就是列的 X 范围。这比依赖页面宽度平均分割更准确,因为杂志栏宽不一定均等。

4.3 合并过程:倒序扫描 + 链式追踪

for (NSInteger col = 0; col < columns.count - 1; col++) {

    // 在当前列,倒序找最后一个"主体文字"的段尾 Block
    // (倒序是为了跳过可能存在的图注、小标题等非主体 Block)
    NSMutableArray *tailBlock = nil;
    CGFloat dominantLineHeight = [self dominantLineHeightInColumn:currentCol];
    for (NSInteger i = currentCol.count - 1; i >= 0; i--) {
        NSArray *block = currentCol[i];
        if ([self lineHeightMatches:block withHeight:dominantLineHeight] &&
            [self isTailBlock:block]) {
            tailBlock = block;
            break;
        }
    }

    if (!tailBlock) continue;

    // 在下一列,找第一个满足条件的段首 Block
    // 字号一致 + 无缩进 + 含句末标点(保证是正文段落,不是纯标题)
    NSArray *headBlock = nil;
    for (NSArray *block in nextCol) {
        if ([self isHeadBlock:block] &&
            [self blockContainsParagraphEndingSymbol:block] &&
            [self lineHeightMatches:tailBlock with:block]) {
            headBlock = block;
            break;
        }
    }

    // 合并:将 headBlock 的所有行追加进 tailBlock,修正 blockIndex 和 paragraphIndex
    [self mergeBlock:headBlock intoBlock:tailBlock];
    [nextCol removeObject:headBlock];

    // 如果合并后 tailBlock 仍是段尾 → 继续追踪到下下列(三栏情况)
    if ([self isTailBlock:tailBlock]) { /* 继续向右搜索 */ }
}

合并时对 paragraphIndex 的修正是一个容易出错的地方。next Block 的 paragraphIndex 从 0 开始编号,合并时需要续接 prev Block 的最大 paragraphIndex,同时修正 blockIndex 保持一致:

for (XLPDFLine *line in next) {
    line.blockIndex     = prevBlockIndex;
    line.paragraphIndex = (line.paragraphIndex - nextBaseIndex) + maxParagraphIndex;
    [prev addObject:line];
}

段落 ID 的设计

完成以上步骤后,每一行都携带了 pageIndexblockIndexparagraphIndex 三个坐标。段落 ID 由此生成:

mgid_pageIndex_blockIndex_paragraphIndex

例如:mag001_3_2_1 表示杂志 mag001,第 3 页,第 2 个文字区域,第 1 个段落。

这个 ID 有两个关键用途:

写入评论时:通过 paragraphIDFromSelection:document:mgid: 生成 ID,与评论数据一起存储到服务端。

读取评论时:通过 paragraphTextFromParagraphID:document: 反向解析 ID,定位到页面 → Block → paragraphIndex,取出对应行集合,用于高亮展示或文字复原。

反向定位的路径:

// 1. 解析 ID,得到 pageIndex / blockIndex / paragraphIndex
// 2. 取出对应页面
PDFPage *page = [document pageAtIndex:pageIndex];
// 3. 对整页重新执行分块
NSArray *pageBlocks = [self pageLinesBlocksFromPage:page document:document];
// 4. 按 blockIndex 取出对应 Block
NSArray *block = pageBlocks[blockIndex];
// 5. 按 paragraphIndex 过滤出段落行
NSArray *paragraph = [self paragraphLinesForParagraphIndex:paragraphIndex inBlock:block];

评论气泡(PDFAnnotation)的锚点应该定位在段落最后一行的位置,这样气泡显示在段尾更自然,同时把段尾行的位置信息传给服务器,服务端也能精确还原气泡坐标。


几个值得关注的工程细节

同行判断的阈值:PDF 中同一行的不同字符因字体 baseline 差异,midY 可能相差 1~3pt。引擎用行高的 50% 作为阈值,而不是固定的 1pt,避免同行字符被误判为不同行:

+ (BOOL)isSameLineByY:(CGRect)r1 rect:(CGRect)r2 {
    CGFloat threshold = MIN(CGRectGetHeight(r1), CGRectGetHeight(r2)) * 0.5;
    return fabs(CGRectGetMidY(r1) - CGRectGetMidY(r2)) < threshold;
}

列 maxX 用中位数baseMaxXForColumn: 返回的是所有行 maxX 的中位数,而不是最大值。这样可以过滤掉个别行尾有标点符号溢出导致的 maxX 偏大问题,让"末行留白"的判断更稳定。

主体行高过滤:在跨栏合并中,用 dominantLineHeightInColumn: 计算列内出现频率最高的行高(取整后做频次统计),作为主体正文的行高基准。倒序扫描段尾 Block 时,只考虑字号与主体行高接近的 Block,这样可以跳过可能夹在正文之间的小字号图注或大字号小标题。


局限性与未来方向

当前实现在以下场景有一定局限:

  • 竖排中文:过滤规则直接丢弃竖排行,不支持竖排杂志。
  • 不规则分栏:栏宽差异极大时(如 1:3 的图文混排),X 轴聚类可能误判列数。
  • 跨页段落:目前只处理单页内的跨栏,跨页的段落连续暂不支持。
  • 表格内文字:表格单元格中的文字可能因行高相近而被当作正文处理。

小结

XLPDFParagraphEngine 的核心设计思路可以归纳为:用几何信息替代语义信息,逐层收敛不确定性

层次 输入 输出 解决的问题
行提取 PDFSelection XLPDFLine 数组 去除噪声行
几何分块 XLPDFLine 数组 Block 数组 区分独立文字区域
列识别 + 分段 Block 带 paragraphIndex 的行 还原阅读顺序和段落边界
跨栏合并 Block 数组 合并后的 Block 数组 修复跨栏断段

整个流水线不依赖任何 PDF 元数据,仅凭坐标和文本表面特征运作,因此对不同来源、不同排版风格的杂志 PDF 均有较好的适应性。

基于Spark的配置化离线反作弊系统

导读 introduction

在作弊手段日益隐蔽和复杂的背景下,单纯依赖在线或实时风控已难以满足深度治理需求。本文系统介绍了一套基于 Spark 的配置化离线反作弊挖掘框架,重点解析其 Extract、Accumulate、Join、Policy 四大核心模块,以及“视图构建”“动态 SQL 生成”“多阶特征计算”“滑动窗口”等关键能力。该框架支持全量历史重算与大规模 Shuffle 计算,通过高度配置化设计,将字段抽取、特征定义、策略判定彻底从代码中解耦,实现策略快速迭代与低成本上线。同时结合数据倾斜治理、列裁剪优化等工程实践,大幅提升稳定性与性能,成为风控体系的重要计算底座。

01 简介

在互联网业务高速发展的大背景下,作弊手段层出不穷,从恶意点击、流量造假,到批量刷单、黑产“薅羊毛”,手法不断翻新、隐蔽性持续增强。这些行为不仅侵蚀了平台的公平秩序,更直接带来显著的经济损失,并严重损害广告主利益和普通用户的体验与信任。因此,全方位、持续演进的反作弊能力已成为互联网产品生态稳定运行的关键基石。

百度基于以上问题构建了一套系统化的企业级反作弊系统,根据时效性和业务需求分为三类:在线反作弊、实时反作弊与离线反作弊。这三类反作弊能力相互补充,共同构建起完整的风控防线,但在防护策略、检测深度和业务价值上各有侧重。

在线反作弊主要负责毫秒级别的请求风险判定,适用于简单规则和轻量级指标,例如从请求头部字段、访问频率等维度快速判断风险,并结合 Redis 等缓存计算实现即时响应。这类机制非常适合于即时性要求极高的场景,例如登录请求拦截或简单阈值规则拦截,但受限于可实时访问的数据维度较少。

实时反作弊在此基础上,通过流式计算分析序列行为、业务上下文和多维特征,在秒级甚至分钟级实现更加精准的策略判定。实时系统能够响应更复杂的行为模式,例如账户连续异常操作、设备跨地域跳变等行为,兼具时效性与一定程度的特征深度,是在线与离线反作弊之间的关键桥梁。具体介绍见基于Flink的配置化实时反作弊系统

然而,在整个百度反作弊体系中,离线反作弊系统的战略价值与日俱上,是构建高精度模型、深度分析行为模式和提升整体风控能力的“底座“

与在线和实时系统相比,离线反作弊不受时效性的约束,可以充分利用完整历史数据进行大规模的批量分析与深度挖掘。其价值主要体现在以下几个方面:

  • 全面的数据视图:离线系统可以访问业务全量日志、用户历史轨迹、跨周期行为等丰富维度的数据,这些数据在在线场景中往往无法实时获取或难以完成整合。
  • 深度行为建模:通过对长期行为序列的分析,可以发现复杂的作弊模式,例如跨账号关联、长期周期异常趋势、人机行为判别等,这些模式在短周期内往往难以捕捉。
  • 特征工程与策略优化:离线挖掘计算出的高维特征是构建机器学习模型的基础,也是实时风控策略得以优化的重要来源。无论是统计类指标、聚合行为分布还是时序特征,这些信号都能够显著提升模型精度。
  • 黑产库与历史知识积累:离线分析能够构建不断增长的“黑产行为库”和风险特征库,支持跨业务线共享和复用。这种长期积累的“经验库”是在线/实时系统难以替代的。

正因如此,百度在反作弊领域投入多年经验,构建了高效的离线挖掘框架,用于批量处理用户行为日志、提取高维特征、训练模型并验证策略,为线上策略提供长期优化与精准判定的动力支持,使整套反作弊体系具备更强的防护能力和持续学习能力。本文介绍该离线挖掘框架的整体架构和设计亮点,并深度解读特征计算链路、性能优化实践以及配置化模块化能力,展示其在刷量识别、账号行为分析、广告作弊治理等场景中的工程价值。

02 离线挖掘框架解决的核心问题

2.1 成本和实现平衡

流式实现特征计算往往需要更高的计算成本,而对于大部分反作弊策略的实现并不需要极高的时效性要求,离线挖掘框架恰恰是解决流式运行高成本,高压力和运行时效进行平衡的媒介,小时级别的产出已可满足大部分业务需求。

2.2 全量历史重算能力和大规模Shuffle

离线的核心优势是:强全量能力 + 强历史回溯能力 + 强复杂聚合能力。

全量历史重算能力:

  • 可以直接扫描全量历史数据(天级、月级、年级)
  • 支持特征逻辑变更后的全量重算
  • 支持复杂回溯计算

大规模Shuffle:

  • 可以做大规模 Shuffle
  • 支持复杂 SQL(多层嵌套、窗口、分组)
  • 支持大表与大表 Join

2.3 多场景数据源和输出灵活对接

离线数据往往面临各种数据格式、表等复杂多样的数据源及灵活多变的输出格式。

  • 数据源类型:目前我们的框架现有数据源支持Turing表, UDW(hive)表, AFS(Parquet, CSV, Txt, PB)文件、用户自定义SQL等,并可以灵活增加wget接入数据源等功能。
  • 输出类型:对于输出也灵活实现了Turing表, UDW(hive)表, AFS(Parquet, CSV, Txt, PB)文件等格式功能,并可以增加输出至clickhouse、doris等存储媒介便于监控分析。
  • 多数据源输入:实现多种数据源同时输入解析,并支持对不同数据源分别清洗过滤,并支持对各数据源单独筛选 & 分区, 实现对不同数据的灵活操控。

03 反作弊离线挖掘框架介绍

3.1 离线挖掘整体框架

百度离线挖掘框架使用生效流程图如下:

图片

上图展示了离线挖掘框架在整个反作弊系统中的使用流程图,即框架在反作弊流程中的使用过程:

  • 用户在配置平台配置 数据源、特征、策略、输出维度等各项配置conf文件。
  • 用户通过配置平台打conf包到对应afs地址, 在TDS平台中筛选集群信息、资源配置等、读取conf配置文件, 并手动调起spark任务。
  • 离线挖掘框架会加载配置信息, 运行spark 任务, 任务结束后将结果输出到 AFS。
  • 用户使用一脉、Jupter等写ETL 任务评估策略是否符合预期, 若符合预期, 则将特征、策略配置上线, 否则修改特征、策略配置等重新运行。

具体离线挖掘框架流程图:

图片

上图展示了离线挖掘框架的整体流程图,分为 extractor 模块、accmulator 模块、joiner 模块、policy 模块等。

Extract (抽取)模块:

抽取(Extractor)模块是离线挖掘框架的数据入口与标准化核心,负责从原始日志或明细表中读取多源行为数据,按照既定 schema 进行字段筛选、类型转换、脏数据过滤和统一格式映射,将分散、异构的原始数据加工为结构清晰、字段规范、可计算的标准行为数据集;同时结合配置文件(如特征或字典配置)完成基础标签补充与维度对齐,为后续的视图构建与聚合计算提供稳定、统一的数据基础。

图片

这张图展示了抽取模块实现的功能:

  1. 输入数据:对原始输入数据源进行解析(包括Hive表,PB日志,parquet数据解析等)
  2. 解析特征配置文件:特征fea_001类型为segment(统计数据),维度为query,条件为:app_id=5&&city=‘北京’,即统计符合条件在app_id=5&&city=‘北京’的每个query的数量。同理特征fea_002为统计符合条件product_id不空的clkip的数量。
  3. 自定义字段:用户可以根据udf函数自定义所需要的字段。
  4. 结果数据:从日志中解析抽取出所有特征中所需要的字段,以图中示例结果为:fea_id,log_timestamp,query,app_id,agent_id,baiduid,product_id,…,其中log_timestamp为必输出数据。

除了 spark sql 支持的所有原生 functions 之外,结合业务实际使用场景,还支持了 多个自定义数据处理算子,并支持用户自定义udf扩展

图片

Accmulate (聚合)模块:

Accumulator(聚合)模块是整个系统的“计算引擎”,负责将海量的原始日志转化为具有统计意义的反作弊特征。基于指定维度和时间窗口对行为数据进行结构化聚合计算,将原始事件流转化为可用于策略判断和模型输入的指标特征。它支持多种聚合算子(如 count、sum、distinct 等)、条件过滤统计以及多维度分组能力,并通过状态管理机制维护窗口内历史数据,实现连续、可配置的特征生成。从工程视角来看,Accumulate 本质上是一个配置驱动的多维度窗口化统计计算模块,是连接原始行为数据与风险决策逻辑之间的关键桥梁。

以下是该模块的详细执行流程及功能解析:

图片

核心流程图解析
  • 数据准备:接收来自 Extractor 的标准化数据,并根据 feature.yml 加载特征定义。
  • 视图构建:这是 Themis 框架的特色,通过 View 和 DataView 概念,将数据按不同的维度(如 baiduid、IP、cookie)进行切分。
  • 动态 SQL 生成:框架不会硬编码聚合逻辑,而是根据配置动态拼接 Spark SQL 语句(如 SUM、COUNT、DISTINCT)。
  • 时间窗口:根据配置文件中的配置的时间窗口进行划分时间段
关键技术特性
  • 视图构建:视图构建,将同一批行为数据转换为带有“统计主体标识”的统一结构,从而支持多维度特征的动态聚合,是面向特征计算的维度抽象层。

在反作弊或行为分析场景中,同一条行为数据可以被多种“主体”统计,例如一条登录行为:

user_id,device_id,ip,cookie,ts

这条数据可以:统计到 user 维度、统计到 device 维度、统计到 ip 维度、统计到 cookie 维度,如果直接写 SQL 聚合,你需要:group by user_id,group by device_id,group by ip,… 。随着维度增加,代码会爆炸式增长。于是框架引入一个抽象,先构建一个逻辑视图,再根据视图去做聚合。

视图构建做了三件事:

  • 维度声明:将原始数据按指定字段组合成不同“统计视角”,这相当于提前确定这个特征是围绕谁统计的?
  • 维度映射:对应维度,记录对应的必要值,例如:(IP具体值,特征id)。
  • 维度参与聚合:不同统计维度通过 view_name / view_value 实现逻辑隔离。
  • 多阶特征计算:随着市场作弊手段的不断提高,普通的一阶策略已经无法识别潜藏的作弊数据,需要更高阶如三阶特征的策略来判定,并便于后期策略的多指标分析。

逻辑: 有些计费名(cntname)下不同的广告位区别很大,需要先算个tu维度的特征,然后tu维度又要先算下面的异常用户占比,就有了这个三阶特征。

例如:

  • 第一层为sn维度的普通比例特征,sn维度ip去重个数除以点击量的比例。
  • 第二层为tu维度,第一层的比例特征大于0.8的sn对应点击占tu全量点击的比例。
  • 第三层为计费名维度,第二层的比例特征大于0.4的tu对应点击占计费名全量点击的比例。

策略依赖的最终特征为计费名维度异常tu点击的比例,即第三层特征。

  • 数据倾斜治理:在聚合过程中,框架会根据配置文件设定开启/不开启识别热点 Key(如超大流量的 IP),广播热点数据,防止任务长尾,具体见4.2。

目前框架能够实现通用特征算子的新增和管理,目前已经支持的抽象化通用特征算子有以下 14 种:

图片

图中时间窗口windows逻辑解释:

在配置文件feature.yaml 中每个特征配置的字段

图片

支持大数据处理中经典的滚动窗口和滑动窗口模式

  • 滚动窗口定义:滚动窗口将每个元素指定给指定窗口大小的窗口。滚动窗口具有固定大小,且不重叠。例如,指定一个大小为 5 分钟的滚动窗口。在这种情况下,将每隔 5 分钟开启一个新的窗口,其中每一条数都会划分到唯一一个 5 分钟的窗口中,如下图所示。

图片

  • 滑动窗口定义:滑动窗口也是将元素指定给固定长度的窗口。与滚动窗口功能一样,也有窗口大小的概念。不一样的地方在于,滑动窗口有另一个参数控制窗口计算的频率(滑动窗口滑动的步长)。因此,如果滑动的步长小于窗口大小,则滑动窗口之间每个窗口是可以重叠。在这种情况下,一条数据就会分配到多个窗口当中。举例,有 10 分钟大小的窗口,滑动步长为 5 分钟。这样,每 5 分钟会划分一次窗口,这个窗口包含的数据是过去 10 分钟内的数据,如下图所示。

图片

Join (关联)模块:

Join(关联)模块是离线挖掘框架中的数据整合层,负责将来自不同视图或不同计算阶段产出的特征结果进行按键对齐与多维关联,通过统一主键(如 user_id、device_id、ip 等)将分散的聚合结果横向拼接成完整的特征宽表;同时处理字段冲突、空值补齐和粒度对齐等问题,确保不同维度、不同时间窗口的统计指标能够在同一维度下合并输出,为后续策略判定提供结构化综合特征数据集。具体是将抽取(Extract)模块与特征计算(Accmulate)模块数据关联, 并以logid进行Group By, 得到PV粒度全量数据, 将特征计算结果拼回各日志中,得到output2 结果 (产出为: log+ feature)。

图片

上图展示join模块的基本逻辑,即将特征聚合模块结果使用logid,拼接到原始日志中,使得抽取模块每条日志拼接到自己所命中的所有特征

  1. 对特征聚合模块(Accmulate)每条结果增加logid字段。
  2. 对特征聚合模块进行logid聚合,多个特征结果聚合到一条logid中。
  3. 抽取模块(Extract) 使用logid,Left join关联logid聚合后的特征聚合模块数据,得到joiner结果。
Policy (策略判定)模块:

Policy(策略判定)模块是离线挖掘框架中承接特征结果并输出最终风险结论的决策核心,负责将聚合产出的多维特征输入规则引擎或策略配置体系,根据预设阈值、组合条件与优先级逻辑进行匹配与计算,生成风险标签、命中规则、风险等级或处置建议;同时支持策略可配置化与版本管理,使风控逻辑能够在不改动底层计算代码的情况下灵活调整,实现特征到业务决策的闭环落地。该模块解析配置的策略文件policy.yaml, 根据policy_id 对 每条日志命中的features 进行策略判定, 输出最终结果,得到output3 结果 (产出为:  log + feature + policy)。

图片

这张图展示了反作弊规则的判定流程:

1.输入数据:每条日志包含多个字段,包括基础字段(如IP、手机号、UID等)、计算得到的特征(如统计特征fea1、fea2等)。

2.策略判定:系统基于预设的反作弊规则,对各字段、特征。例如,规则1要求【fea_001 > 100 && fea_002 < 10】,规则2要求 【IP like ‘192.%’ && fea_002 > 100】。多个规则都会执行判定逻辑,判断是否命中。

3.结果输出:最终的PV数据会带上反作弊命中结果。例如,在示例中,该PV数据命中了policy_002,表明该行为可能存在风险。

以上就是策略配置的所有介绍,通过配置化管理字段、特征、词表、模型和规则,反作弊系统能够快速响应业务需求,灵活调整检测逻辑。同时,配置化设计大幅降低了开发部署成本,提高了策略迭代效率。

3.2 流程汇总

以上3.1介绍了离线挖掘框架各个模块实现的功能,代码实现以scala的dataframe容器作为各个模块之间数据传输的媒介,此处以dataframe的计算步骤来汇总介绍框架是如何进行数据传输。

图片

04 离线挖掘框架设计亮点

4.1 模块化工程架构思想

框架整个代码实现力求模块化、轻量化;便于并行开发和测试,对后期维护升级铺平渠道。

图片

以上图为工程实现图,步骤解释:

  1. 通过TDS/spark-submit提交spark job

  2. runner调用context的init()方法,进行框架配置任务初始化

  3. init()过程中调用ConfLoader和DictLoader加载配置文件、词表,以及注册udf等等初始化操作

  4. init()返回封装好的context对象

5、6、7、8、执行各模块,将计算结果保存至context

9.根据配置的round轮数,输出对应结果的df

从运行图可以看到,这套离线反作弊挖掘框架并不是简单的“Spark 作业集合”,而是一个具备完整工程设计理念的 可编排计算引擎。其核心设计思想体现在四个方面:统一调度中枢、数据上下文抽象、算子标准化编排、配置驱动解耦

1. 统一调度中枢:构建“作业引擎”而非脚本集合

框架以 OfflineThemisRunner 作为唯一入口,负责生命周期管理、流程调度和执行编排。所有模块均由 Runner 驱动执行,而非模块间直接调用。体现“控制流集中管理,业务逻辑分散执行”。

工程优势:

  • 统一异常处理
  • 执行流程清晰、可追踪
  • 支持任务模板化和标准化运行

2. Context 抽象:解耦控制流与数据流

整个计算链路通过 Context 进行数据承载。各算子只与 Context 交互,而不直接依赖其他算子。

工程优势:

  • 消除模块间的强耦合
  • 实现数据语义统一管理
  • 支持中间结果复用与调试
  • 允许执行顺序灵活调整

从架构角度看,Context 是框架的“数据总线”,将数据流从算子依赖关系中剥离出来,使系统具备真正的模块化能力。

3. 算子标准化:构建可组合的计算流水线

框架将特征计算拆分为四类标准算子:Extractor(抽取)、Accumulator(聚合)、Joiner(拼接)、Policy(过滤)

所有算子遵循统一接口规范(run(context)),输入输出标准化,将复杂业务逻辑抽象为标准化计算单元

工程价值在于:

  • 新特征开发只需实现算子接口
  • 降低复杂链路的维护成本
  • 便于统一优化与性能调优
  1. 配置驱动:将策略从代码中剥离

通过配置来驱动计算流程和策略逻辑。代码负责能力,配置负责策略。

具体配置功能见4.3

4.2 运行优化

1、解决数据倾斜

在Accumulate特征聚合阶段,使用到groupby进行聚合操作,如果热key数据量大的情况下导致单个 Task 处理大量数据,即会出现严重的数据倾斜,甚至导致 OOM / 失败重算。

图片

以上图的优化思路:采样识别 + 拆分 Join (Skew Join)

  • 首先用 Spark API 的 sample() 统计左表 key 出现频次,先采样找出热点(大 Key)
  • 将左表按是否热点拆分
  • 将右表也对应拆分
  • 对热点 Key 用广播 Join ,避免 Shuffle
  • 非热点 Key 按常规 Join
  • 最后union all两份数据得到最终结果

对于采样解决数据倾斜已经配置化,用户可根据实际需求自定义配置是否启动优化和采样的比例,具体见4.3

2、列裁剪优化

Join拼接模块阶段,在优化前使用炸开后的Extract数据 Left Join Agg结果(view_name,view_value,window_start<=time_col<=window_end), 获取结果数据(Joiner), 结果数据包含(neededViews + agg聚合结果)。

我们假设:

1). 抽取出的Extract中含100个neededViews字段

2). feature.yml中feaList包含了80个featureId

那么就会出现以下情况:

1). 假设某条数据命中了50个feature条件,那么这条数据的聚合结果就有50条

2). 对Extract进行爆炸,也会爆成50条

注:

1). 以上方式使用Extract Left Join Agg结果时,每条数据会被扩充几十甚至上百倍,若每条Extract数据字段较多,则会造成很大的数据冗余,这些数据并不参与计算,浪费计算资源。

2). 因此再通过此方法进行group by聚合操作,浪费了很多不必要的内存,很容易发生数据倾斜,计算速度也会很慢。

图片

以上图为列裁剪后的优化,优化思路为:

其实优化前第一步的操作就是为了将logid赋值到每一条ACC特征计算结果上,那样接下来才能进行group by logid操作。

  1. 我们先对抽取模块结果列裁剪logid和关联键的hash()值,和特征计算模块同样的关联键的hash()进行join。

  2. 再对特征计算结果进行group by logid操作,就能减轻许多计算压力。

  3. 最后用Extract Left Join第2步的结果即可。

综上,经过列裁剪及聚合下沉操作后,实际工程速度在列数较多场景下均提升60%以上,并有效防止OOM,降低任务失败率。

4.3 配置化

为了满足反作弊策略快速上线、精细化模拟验证和灵活联调等高频迭代需求,我们的实时反作弊系统采用了高度配置化驱动架构,并将所有配置集中托管平台上进行统一管理。

在这一体系下,策略和计算逻辑不再硬编码到程序中,而是通过规范的配置文件描述出来,从字段抽取、特征定义、规则判定到结果产出,每一个步骤都可以通过配置完成。策略开发人员只需在平台上配置好各项参数,系统即可自动生成对应的作业,并支持一键打包和上线执行,大幅缩短了业务上线周期,降低了对底层框架开发的依赖。

图片

策略配置主要由以下几类配置模块组成:

主配置:全局环境配置,这是框架的主配置文件,定义了任务运行的基础环境和全局参数,控制任务的运行模式、资源分配和全局开关。

  • 输入输出:该配置决定了框架的输入地址、输入格式、输出地址、输出格式、控制框架需要的输出阶段等,例如round1,round2,round3。
  • 优化:还可在此配置中配置是否开启抽样优化及抽样的比例等。
  • udf自定义函数:用户可以自定义udf函数。

字段配置:负责将各种来源、各种格式的原始日志映射为框架可识别的标准字段。我们将字段抽取逻辑进行了配置化抽象,策略开发人员使用类似于写sql的方式即可完成简单字段的etl逻辑的开发,如常见的json字段抽取,字符串处理,反作弊内部的常用UDF等,配置能覆盖大部分字段抽取。根据抽取方式不同分为:

  • 基础字段:直接从原始数据流中提取的字段,例如设备 ID、用户 ID 等。
  • 二次计算字段:简单的字段转换逻辑(如 IP 转地域、UA 解析)。
  • 维表字段:通过查询词表映射关系获得的字段,例如黑名单匹配结果、分类标签等。

特征配置:特征是策略的重要判定依据,定义了如何从标准字段中计算出用于反作弊判定的统计特征。特征配置包括以下几个关键方面:

  • 特征类型:数据的聚合方式,如sum、count、distinct等。
  • 窗口信息:设置聚合特征的时间窗口范围和窗口形式,时间范围如:1 小时、1天等,窗口形式如:滑动窗口、滚动窗口等。
  • 特征维度:特征的聚合维度,如用户、设备、IP 地址等。

词表配置:词表通常是历史已知的黑名单、字段映射(如ip映射城市)等固定维表信息,在数据进入引擎之前,利用词表进行初步的“脏数据”清洗或黑名单过滤,提供外部参考数据,用于过滤或打标。配置内容需包括以下几个方面:

  • 词表路径:指定词表的存储位置,支持文件路径或分布式存储地址。
  • 词表类型:支持多种形式的词表,包括集合(set)、键值对映射(kv)、正则表达式(regex)等。

策略配置:规则配置决定了作弊行为的最终判定规则和处置方式,组合特征,输出最终的作弊名单或风险评分:

  • 策略判定阈值:定义触发策略的条件,例如基础字段匹配、词表匹配、风险评分的阈值、特征累积阈值、模型打分阈值等。
  • 策略判黑等级:设定风险等级,区分低、中、高风险及对应的处置措施。

以上总结配置文件的各个功能如下:

图片

05 总结

本文介绍了基于spark 的离线反作弊挖掘框架,围绕解决的基本问题、工程设计亮点等展开。通过特征计算和配置化管理,提升了反作弊系统的检测效率和稳定性。展望未来,离线反作弊挖掘框架将持续演进,与更多智能算法、大模型和业务系统深度融合,不断完善检测能力和可用性。借助持续优化的特征计算与策略模块,此框架将为百度生态提供更加坚实的反作弊保障。

国内首个硅谷科创展团将入驻AWE2026东方枢纽展区

当全球科技目光聚焦于AWE2026,东方枢纽展区即将迎来重磅阵容——国内首个硅谷科创企业展团确认参展亮相。这既是展会的一大标志性亮点,也为全球科创产业合作打开了新的对话窗口。

这将是硅谷科技企业有史以来首次以规模化展团形式亮相国内展会。展团由ChatGPT问世之际创立于美国硅谷的硅谷科技评论(SVTR)发起,集结15家硅谷前沿科技企业,将在东方枢纽展区集中展示其前沿技术与创新产品。与此同时,一支由近60位硅谷高端企业家、资深科技人士与核心买家组成的参观团也将同步参展,与全球科技企业展开深度交流与合作对接。

作为全球科技创新的策源地,硅谷长期以来在信息技术、半导体、互联网、人工智能等领域引领着全球科技发展的潮流。展团阵容覆盖人工智能、工业机器人、多模态传感、空间智能、企业服务、脑机健康、智能硬件等多个科技创新前沿赛道的优秀企业,汇聚颠覆行业认知的创新力量和面向未来的技术想象力,将以突破性技术与产品全方位展现硅谷式创新实力。

其中,Mantis Robotics专注于无围栏工业机器人与人机协作,凭借其革命性的“物理智能(Physical AI)”技术,重新定义了人机协作的传统模式。研发的旗舰产品MR-1机器人,专为解决自动化套件装配的独特挑战,适用于需要灵活自动化解决方案的场景。

Aora凭借先进的无创神经传感器技术,打破传统可穿戴设备的局限,直接捕捉大脑的电生理活动,通过AI算法分析实时提供关于认知表现、压力水平、专注度与脑力疲劳的深度洞察,为用户提供个性化报告和科学调节方案。

总部位于韩国的智能发酵科技公司Toasterz,致力于为家庭烘焙爱好者与小型手工食品创作者打造人工智能驱动的生态系统,立足智能厨房硬件与数据驱动食品自动化的交叉赛道。其智能发酵旗舰产品SourFriend将精密温控硬件与预测式AI算法相结合,实现家庭场景下酸种面包发酵的标准化,并通过自适应控制优化发酵时长与品质稳定性。

打造了全球首个信息通信技术(ICT)融合的团队无人机竞技平台的SpaceWar,其核心平台融合激光雷达(LiDAR)实时定位服务(RTLS)、AI自动计分系统与沉浸式VR直播技术,带来全数字化体育体验。与传统无人机竞速不同,其自研系统通过智能LED门控系统,可实时追踪、计分并可视化呈现竞技数据,打造出媲美电竞、却立足于现实物理世界的观赛体验。

Cephia于2023年成立于美国加利福尼亚州旧金山市,其基于超表面技术的一体化多模态传感平台,集成视觉、听觉、触觉、力觉等多种感知能力,用超表面替代传统镜头,解决传统相机体积大、计算延迟高的问题,在光域完成部分AI计算,处理速度比传统电子计算快200倍以上,能耗降低90%。

专注于AI驱动解决方案的科技初创公司Orion,打造了可在手机、电脑、平板等任何设备上控制的个人助手分布式边缘AI个人助手,突破传统云端助手的局限,将AI计算能力部署在用户设备本地,同时实现跨终端无缝协同控制,一站式解决个人数字生活的智能化需求。

专注于为物理AI提供可扩展数据基础设施和远程操作平台的科技公司Avea Robotics,其面向机器人开发团队的统一远程操作平台,实现低延迟控制、沉浸式可视化、直观控制界面,满足即插即用连接和灵活适配能力。

空间智能领域,Atlas Spatial Intelligence通过构建数字孪生模拟基础设施,为机器学习提供低成本、高效率且自带精准标注的物理世界动作级数据。对于室内场景,其数据可用于机器人训练,对于室外场景,其构建的城市级三维矢量地图可用于无人机导航。

Xronos以工业级平台赋能下一代智能系统,可轻松从原型验证扩展到规模化部署,为机器人、自动化与移动设备提供安全、可靠的控制,大幅提升开发者效率,并在边缘侧实现AI能力落地。

Griting专注于用智能系统给企业、高校、快节奏初创企业提供AI人才审核、孵化、再审核服务。其产品由藤校AI博士团队打造,拥有自主研发的AI评估算法,能深度理解技术人才能力。

Trace为硬件团队提供设计层面的支持。它能处理整个PCB工作流程,让团队无需亲力亲为。从概念到电路图、原理图绘制、数据表解析、元件选择、布局、布线、设计规则检查(DRC)以及Gerber文件导出,一应俱全。

Prosgrow AI是NVIDIA初创加速计划的成员,专注于为AI与科技初创公司提供资源匹配与商业增长服务,利用NVIDIA的GPU和AI开发资源来驱动会议GTM代理,筛选潜在客户、捕获意向并分配线索。

Sayge致力于通过AI赋能商业自动化与智能决策,打造了基于“Live AI Agent (LAA™)”的决策智能平台,帮助企业在内容发布前实现毫秒级验证,替代昂贵耗时的传统人工调研。相比通用大模型,依托2700万+真人数据构建的LAA™模型,达到87.5%的细微决策准确率,实现了从“预测结果”到“还原思考”的质变。

而Spawnlabs则打造了“描述即构建”的软件生成平台,能将创意转化为可自主维护的实时系统,覆盖从个人工具到企业级运营的需求,以自动化工作流程一站式解决问题。

此外,一个由硅谷科创展团负责人推荐的面向HBCU学生与校友创始人的孵化器平台项目也将参展。其创始人目前获得奥巴马学者荣誉称号,管理团队成员具有跨领域经验,涵盖技术教育、政策研究、数据科学等领域。

从脑科学监测到工业自动化,从智能决策到空间智能,硅谷科创展团将以”技术+人才+产业”的全面阵容,在AWE2026东方枢纽展区展开一场创新基因与市场活力的深度交流与碰撞。这不仅是一次历史性的参展,更是一场全球科技产业深度交流的旅程。

3月12-14日,在东方枢纽展区共赴一场引领未来的科技之约,与硅谷科创展团一同见证创新成果的集中亮相,感受硅谷创新潜力跃入国内产业浪潮的活力与动能!

AWE2026东方枢纽国际商务合作区展区为消费电子前沿科技专业展区,聚焦AI芯片、算力、6G、具身智能、新能源等硬核科技领域,主要服务行业人士、采购商、媒体等专业观众,采用提前预约、审核入场制度,无现场注册通道。专业观众需提前24小时完成线上注册与资质审核,凭审核通过凭证及有效证件入场。

扫描二维码即可报名观展

透视“速成车”,行业不可承受之快

“速成车”踩刹车

2026年一开年,监管部门再一次收紧了对汽车开发的要求。这让不少“老汽车人”松了一口气。

一名头部车企的软件总工告诉36氪,在他十余年的从业生涯里,从未经历过去几年的激进时刻:

原本需要适配两年才能上车的新架构,现在10个月就得上车;之前机械验证要做的两次冬测试、两次夏测,被压缩成了一次冬测、一次夏测。

原本要验证4个月的整车控制软件,现在2个星期就可以上车。

他这种内行人才知道的隐忧还有:看似测试环节都在,但压缩时长伴随着高强度加班,实际操作中,需要测200次的项目最后可能只测了30次;测完10次,员工便可能说已经测过100次了。

“有些问题就会测不出来,其中可能包含高安全相关问题。比如经常车卖出去,门把手出不来等等B类问题还有一堆。”

因为智能电动车普遍的特点是可以空中升级,不少车交车时,一些软件还未开发完成,公司就寄希望于后续推出软件更新,这自然包括使用更新来修复验证不充分导致的问题。

一位头部造车公司的辅助驾驶人士说,“为了改进刹车体验,虽然有一版软件带有制动时机偏晚的安全隐患,也被推了出去。”

“因为觉得反正可以OTA(Over-the-Air 远程在线升级),智驾老大顶住压力推了新版,结果撞车事故马上增加。”据36氪了解,因数次仓促OTA造成质量问题,这名智驾负责人也不得不引咎离职。

在手机行业,华为的旗舰机型Mate系列、小米数字系列手机的研发周期,约为12-18个月,一款机型的生命周期约为2年。

而如今,在拳拳到肉的车市竞争下,汽车行业一辆整车的开发节奏,已经普遍从3-5年,被压缩至一年半,甚至更少;汽车产品两年一换代,每年一升级,也已经成为主流车企的共识。

这种“中国速度”即是中国新造车业的竞争法宝,让人引以为傲;另一方面,无节制的卷生卷死,也带来了车辆的质量问题。

去年8月,监管部门推出OTA备案规定,首先遏制速度竞赛带来的软件乱象。

今年初,开始从整车层面对“速成车”的监管进行收紧。1月29日,工信部修订发布了《道路机动车辆产品准入审查要求》,一个核心变化在于:要求传统燃油车需完成3万公里可靠性测试,新能源汽车需完成1.5万公里。这是国家首次将可靠性测试纳入强制性规定。

如果说软件验证不充分,尚可通过在线升级加以补救,但硬件验证不充分,一旦有质量问题,车企则要付出沉重代价。

以新能源车上最关键的零部件——动力电池为例,36氪从一位电池总工处了解到,几年前他所在的企业,测试电池循环寿命的方法是,让电池衰减到70%的健康度,再统计中间经历了几轮充放电循环,循环次数过关才能量产。

但如今,一方面宥于整车开发周期,另一方面电池技术也在成熟,“有的电池厂商可能衰减到80%,有些甚至90%,就开始量产了”。这位电池工程师表示,这些电池目前尚未出现大规模质量问题,但“有时候电池的问题,就是需要以年为单位才会暴露的”。

去年10月,理想MEGA召回就是在验证上“掉以轻心”的典型案例。爆燃事件刷屏全网,理想花费11亿元召回1.14万辆MEGA,更沉重的代价是,理想MEGA和纯电车i8此后的销量,开始断崖式下降。

这起事故的起因正是被当作成熟工艺、大部分车企不再特别关注的冷却液。多位参与理想MEGA项目的工程师告诉36氪,理想和供应商,都未对新采用的低电导率冷却液与冷却铝板之间的腐蚀问题,进行充分验证。

去年9月,小鹏因方向盘锁死,对部分P7+车型发起的召回,是行业一起重大召回案例。

接近小鹏的人士告诉36氪,方向盘锁死,线束接触不良是一方面。另外,小鹏曾对转向系统某子零件做了一定设计变更,却没有进行更充分的耐磨性测试。

“一次设计变更后的可靠性验证测试,零部件端至少需要1-2个月,整车厂系统级别也需要1个月。”产业人士告诉36氪,“但现在大部分企业都没法像以前一样做转向耐久了。”

而2026年一开年的新规,就将监管重心瞄准了整车验证。有研发人士告诉36氪,家用汽车的年均行驶里程通常在2万公里左右,而主流企标通常测试10万公里水平——所以新法规暂未对行业构成实际压力。

但监管部门的这一举动已经向外界传达了一个信号:耐久与可靠,必将成为今后监管的重点。

速度之鞭,抽动汽车行业

对速度的饥渴是中国车市高强度竞争的缩影。

这个残酷的行业底色,比亚迪董事长王传福在2023年时有过形象的比喻:“行业不是大鱼吃小鱼,可能是快鱼吃慢鱼。”

深谙行业竞争法则的比亚迪,自然会在产品节奏上突出一个“快”字:比亚迪海洋网部分车型半年左右推出一年度改款,两年左右进行一次大换代。车海战术+快速迭代,正是比亚迪产品席卷新能源车行业的方式之一。

除了比亚迪,曾经的民营车企销冠吉利,也很清楚“快”对自己而言意味着什么。

有吉利人士告诉36氪,吉利的产品策略就是跟进、对标,友商发布一款爆款车型后,吉利只有在半年内跟进,才不会失去市场时机。所以吉利每年有上百款车型,很多车的开发周期都在18个月,甚至更短。

反之,理想汽车就在2025年吃到了速度慢的大亏。

理想增程系列一度引领新能源车大型SUV市场,但是当公司把节奏规划为4年一次大迭代后,立马被同行追上。公司成为在2025年唯一销量下滑的新造车公司。

“过去多年,我们最大的教训,就是没有建立起产业壁垒:我们给供应链定点的东西,供应商会马上卖给其他车企,连定点程序都不走。”理想汽车一位高管向36氪表示,而一个硬件的追赶节奏,普遍只在半年到一年时间。

华为、小米等头部消费电子品牌的跨界造车,让这场关于速度的游戏持续升维。

小米汽车在2024年4月推出了SU7车型,这款新车上市24小时,大定订单便突破8.9万台。爆品势能下,小米于2025年6月推出的SUV车型YU7,更是开售3分钟揽下20万大定订单。

有市场人士告诉36氪,过去的市场竞争,更像是零星几个头部品牌友好商量如何瓜分市场。“就拿20万-30万来说,现在特斯拉和小米占掉一半市场,赢不了他们,就只能和其他十几个品牌一起平分剩下的小份蛋糕,我们只能选择快速跟进它们”。

速度之鞭抽动整个行业,品牌势能强如特斯拉,也不得不应对这样的竞争态势。

一家特斯拉供应商人士向36氪回顾了焕新Model Y车灯的开发过程。

规划焕新Model Y时,特斯拉并没有想到,贯穿式大灯很快会成为中国市场的消费主流。特斯拉焕新Model Y项目在2022年底启动,“已经开发了一年,特斯拉突然要求改变尾灯样式,要做贯穿式的漫反射尾灯”,该人士向36氪表示,当时特斯拉注意到比亚迪、理想等品牌新车都在用贯穿式尾灯。

从发起设计变更到最终量产,新款车灯最终的落地周期不足15个月。

“外观是汽车开发周期中首先要确定的东西,现在中国市场灯光技术和汽车设计都在快速变化,3-5年的开发周期后,已经确定的设计很有可能还没量产就已经过时”,有产业人士告诉36氪,“为了产品不过时,车企也必须加快节奏”。

车企们正陷入追求速度的“囚徒困境”:对手快,自己只能更快;对手放低可靠性标准,自己也很难坚守——“囚徒困境”往往以双输/多输结尾。

速度与安全能否兼顾?中国车企们怎样才能避免走入集体性失利的局面?

趋势不可逆,底线不能退

“速度”已经成为中国汽车业不可逆的趋势。

汽车工业老人们对于“耐久性”的执念,有其特定的历史背景。以德国市场为例,由于车速高、修车贵,有老车和二手车文化,德国的平均换车周期是14-15年。在这样的市场,容易坏、不耐开的车根本卖不出去。所以德国品牌反复宣传其耐久测试里程长达百万公里。

但中国市场现状截然不同。中国汽车流通协会数据显示,新能源汽车的平均换车周期已从燃油车的6–8年缩短至3–5年。面对AI大模型上车、L3级自动驾驶逐步落地的技术浪潮,消费者否还愿意使用一辆车长达15年,答案已经不确定。

“3万公里耐久测试,成本就是百万元级”,该人士表示,“消费者只需要一辆开10万公里的车,企业做500万公里的测试,额外的成本谁来承担?”

刹车验证也要看地区特性。中国道路最高限速均在120km/h以下,与德国不限速的用车环境差异巨大。所以,在德国卖车,必须保证车辆任何时候在260km/h下都能刹停,但在中国,消费者们最多只开到130km/h。

有研发人士告诉36氪,因此个别中国车企在刹车性能上的内控标准仅为140km/h,“这能大幅缩短周期,反正超速事故的责任主要是在驾驶人本身”。

因此,中国新能源车的耐久性标准,本就不应该比照传统标准。

速度与可靠性,也并非完全对立。

一个“两全其美”的办法,是采用零部件标准化、供应链共用率高的汽车架构平台——这正成为如今最主流的、加速产品开发的手段。

有供应链人士告诉36氪,吉利旗下的单车项目,从供应商定点到SOP,最短的仅用6个月时间,“底盘有些系统甚至直接复用,整车零部件复用率应该超过70%了,这可以省去3-6个月的开发验证时间”。

企业对平台化的进一步追求,体现在“前端定义”与“技术预研”上。

以最耗费时长的底盘调校为例,有研发人士向36氪表示,目前绝大部分企业都是造一辆车、调一辆车。骡子车阶段调两轮1个月,确定风格;DV阶段精调3个月、PV阶段一致性调节1个月;试生产后还需要1.5个月用以最后调校,“一家有功底的企业都需要6个月时间”。

平台化后,研发部分前期便可以对平台内所有车型进行统一设计,底盘调校周期可以缩短至少50%,最短2个月即可完成,“过去一车一调太费时间,这种模式正在成为行业主流”。

在对速度的追逐中,汽车从业者们的工作强度也卷到极限。

吉利一位工程师向36氪回顾旗舰车型极氪9X的交付前夕,“当时参与极氪9X项目的,有数千人。赶项目节点的时候,几千人的团队,每人每月的日均工时达到了12-13 个小时,很多时候半夜12点都回不去”。

去年年中,极氪9X上市前几个月,眉山工厂每天都有人两班、三班地轮流倒班测试。有一拨人专门在夜里工作,从晚上8点干到早上8点。“因为用来测试的工程车就那么多,时间又紧,只能这样轮流用。”

反观汽车开发周期仍长达3-5年的外资品牌,其员工的工作状态截然不同。“宝马北京办公室晚上基本不亮灯;奔驰德国团队,今天还能一周居家办公3天”,有行业人士告诉36氪。

竞争压力下,外资品牌想在中国市场生存,也在加快周期。

有奔驰研发人士告诉36氪,2023年启动的长轴距版纯电CLA,本地化开发工作共用时两年多,“2026年即将推出的长轴距版GLE,从项目启动到SOP,目前规划市场仅为13个月”。

汽车正不可逆转的向智能化终端靠拢,开发工具也在持续进化,叠加流程优化,以及最大限度挤出工时水分等多重因素,汽车开发周期的缩短也正成为不可逆的产业趋势。

但对于速度竞赛之下的质量底线,车企同样不容退让。

2010年,丰田因“刹车门”在全球范围内召回超1000万辆车,时任社长的丰田章男曾亲口承认,丰田扩张太快、罔顾人才培训与质量检验,为汽车质量问题埋下隐患。该事件后,丰田口碑面临信任坍塌,多个地区销量多年下跌。

2013年,大众汽车在中国年销达到历史性的327万辆。销量狂奔背后,公司压缩验证周期,导致变速箱隐患集中爆发。不仅在当年315晚会被曝光,更是在国家质检总局的要求下,召回近百万辆车,成为中国汽车产业监管领域的标志性事件。

当今,汽车产业已成为中国国民经济的重要支柱,其中智能电动车领域更是在技术与市场规模上双双引领全球,不仅为中国契合科技出海开辟了道路,也为中国在全球技术竞争中赢得关键话语权奠定了坚实基础。

但这只是阶段性的突破,监管必将步步跟随,确保已经取得的产业硕果长青式发展。

阿里辟谣大模型团队集体离职:团队稳定,服务正常

36氪获悉,3月5日,针对近日网络流传阿里巴巴“千问模型核心团队集体离职”“开源策略调整”等不实信息,阿里集团表示:目前千问模型团队稳定,没有出现“集体离职”的情况,所有产品与服务运行正常。千问会坚持开源策略。基础模型团队从未被设置DAU等商业化KPI,Qwen大模型的目标是不断追求模型智能上限,实现AGI。阿里巴巴诚挚欢迎全球顶尖AI人才加入,共同打造世界级的大模型技术与开源生态。阿里巴巴将持续加大投入,为千问团队提供坚实支撑。

德赛西威:拟发行H股股票并在香港联合交易所有限公司主板挂牌上市

36氪获悉,德赛西威公告,为进一步推进公司国际化战略布局,提升公司品牌国际影响力,加快海外业务拓展,打造国际化资本运作平台,助力公司高质量发展,提升公司整体竞争力,同时充分借助国际资本市场的资源与机制优势,优化资本结构,拓宽多元融资渠道,公司拟发行H股股票并在香港联合交易所有限公司(以下简称“香港联交所”)主板挂牌上市。

山金国际:2025年净利润同比增长36.75%,拟10派4.8元

36氪获悉,山金国际发布2025年年度报告。报告显示,公司实现营业收入170.99亿元,同比增长25.86%;归属于上市公司股东的净利润29.72亿元,同比增长36.75%。公司拟向全体股东每10股派发现金红利4.8元(含税),送红股0股(含税),不以公积金转增股本。

大金重工:2025年净利润同比增长132.82%,拟10派0.87元

36氪获悉,大金重工发布2025年年度报告。报告显示,公司实现营业收入61.74亿元,同比增长63.34%;归属于上市公司股东的净利润11.03亿元,同比增长132.82%。公司拟以637,749,349股为基数,向全体股东每10股派发现金红利0.87元(含税),送红股0股(含税),不以公积金转增股本。

比亚迪计划到2026年年底建设落成2万个闪充站

比亚迪3月5日发布“闪充中国”战略,计划在全国范围内批量建设闪充站,预计到2026年年底建设落成2万个闪充站,其中包含1.8万座“闪充站中站”和2000座“闪充高速站”。截至目前,比亚迪已建成4239座闪充站。(界面)

欧盟最终批准2040年气候目标

当地时间3月5日,欧盟理事会正式通过了修订后的《欧洲气候法》,规定到2040年,温室气体净排放量比1990年水平降低90%。修订后的法规将在《欧盟官方公报》发布20天后生效,并直接适用于所有欧盟成员国。欧盟委员会将提出相关提案,以落实具有约束力的欧盟2040年气候目标。(央视新闻)

*ST辉丰:拟1.1亿元转让大丰农商行3.9%股权

36氪获悉,*ST辉丰公告,公司于2026年3月4日召开董事会,审议通过转让所持大丰农商行3.9%股权给海城实业,交易价格为1.1亿元。此次交易不构成重大资产重组,也不构成关联交易。转让后,公司仍持有大丰农商行1.7903%的股权。该交易有助于增强公司资金流动性,对本期损益不产生重大影响。

Coze+ VisActor Skill:智能图表,触手可及

Coze 技能商店简介

Coze 简介

Coze(扣子)是字节跳动推出的新一代 AI Agent 平台。

使用入口

  • 公开版(国内):coze.cn(网页端)/ 扣子 APP(移动端)
  • 公开版(海外):coze.com

核心能力

扣子提供了强大的功能,全面提升生产力,支持多种应用场景,满足不同用户的需求。

  • 生产力全面提升:从回答问题,到解决问题,让 AI Agent 帮你完成更多的工作。
  • 技能:AI 从“理解指令”向“掌握方法”跨越的核心。
  • 能力边界拓展延伸:MCP 扩展集成,无限拓展 AI Agent 能力边界。

Coze 技能商店

熟悉AI 应用的同学,对 skill 的概念和应用一定不陌生,Coze 也推出了 技能商店,各种技能如雨后春笋般涌现。

VisActor图表技能

VisActor 图表技能以其图表种类丰富、图表主题美观、智能配置灵活、代码生成准确 等诸多优点,在众多图表(可视化)技能 中脱颖而出。

VisActor 图表技能背后依托 字节跳动开源可视化解决方案 VisActor,基础组件和智能化生成能力千锤百炼。目前的图表Skill 背后主要依托 VChart 和 VBI 组件库。

官网visactor.com/visactor.com/vchartvisactor.github.io/VBI/

githubgithub.com/VisActorgithub.com/VisActor/VC…github.com/VisActor/VB…

VisActor 图表技能快速体验

通过链接 www.coze.cn/?skill_shar… 或者 在Coze 技能商店搜索“VisActor” 都可以找到 VisActor图表技能。

打开卡片弹窗,点击“使用”。

接下来,我们可以在Coze中 @ VisActor 图表技能来实现可视化了。

输入需求之后,静静等待Coze执行任务。

Coze 会自动搜集数据,然后调用 VisActor 图表skill 生成图表。

VisActor 图表skill 会生成一个可以交互的图表,而且你可以通过修改VSeed spec 的方式进一步对图表进行调整,满意之后可以下载图片使用,同时可以分享页面链接。

VisActor 图表skill 支持丰富的图表类型,具体可参考 www.visactor.com/vchart/exam…

数据透视

我们也可以传入自定义数据,供 VisActor 图表skill 进行分析。例如:

VisActor 图表skill 具备其他同类skill 不具备的数据透视能力。例如:

运行结果如下:

多轮编辑

VisActor 图表skill 提供了强大的编辑能力,用户可以持续在Coze中通过对话修改图表。例如这里我可以继续将一组柱图变成红色。

数据标注

数据标注也是 VisActor 图表skill 独有的强大能力。比如,我想标记一条折线的最高点。

更多标注能力可以参考:www.visactor.com/vchart/demo…

动态图表

动态图表多用于数据作品或者数据视频中,可以动态展示数据变化过程, VisActor 图表skill 目前提供了动态条形图,动态折线图,动态饼图,未来会追加更多的动态图表。

出错了怎么办?

因为用户的需求千差万别,偶尔会有无法生成或者出错的情况,用户可以将报错信息同步给Coze,让它修复重新生成。

当然我们会搜集用户的反馈,不断改进。

信息图、数据视频——值得期待

单一的图表生成并不能满足可视化的应用场景,接下来我们会推出信息图和数据视频的生成功能,敬请期待。

欢迎交流

最后,欢迎大家以各种方式参与 VisActor 的开源建设,提 issue,写代码,加评论,点 Star,都是对开源项目的支持。

VisActor:visactor.com/

VBI:github.com/VisActor/VB…

VChart:GitHub - VisActor/VChart: VChart, more than just a cross-platform charting library, but also an expr

VTable:github.com/VisActor/VT…

VisActor 飞书交流群:

VisActor 微信公众号:

❌