在数字孪生项目里,最让团队头疼的事情往往不是3D场景做得不够漂亮,而是数据传不过来、画面卡顿得像翻PPT。后台传感器已经发了一堆数据,前端Three.js场景里的设备状态还是纹丝不动,用户看着就着急。这个问题不解决,数字孪生就只是个好看的外壳,没法真正落地干活。

一、延迟到底卡在哪里

1.1 数据链路的三个阶段

数据从传感器到最终显示在屏幕上,中间要经过采集、传输、渲染三个环节,每个环节都可能拖后腿。采集阶段一般问题不大,传感器采完数据就丢给中间件处理。真正让人头疼的是传输和渲染这两步。传输环节如果还在用HTTP轮询,比如每隔500毫秒请求一次接口,那数据最多要等500毫秒才能到前端。渲染环节更夸张,如果前端收到数据后每次都重新创建Three.js的几何体和材质,哪怕只是改一个颜色,整个场景都会掉帧。

1.2 延迟感知阈值

做过数字孪生项目的都知道,人的肉眼对延迟是有感知阈值的。100毫秒以内基本感觉不到画面延迟,100到200毫秒之间开始有点不舒服,超过300毫秒用户就会直接说"这卡了"。所以优化目标很明确:从数据产生到画面上更新的时间要控制在100毫秒以内,最晚也不能超过200毫秒。

二、Three.js 基础场景与数据模型

2.1 技术栈说明

本文所有示例统一使用 Three.js + WebSocket + Node.js(Express + ws) 这套技术栈。Node.js负责数据推送,WebSocket负责低延迟通信,Three.js负责3D渲染。

2.2 搭建基础场景

先搭一个简单的Three.js场景,模拟一个工厂里的设备看板:

// 技术栈:Three.js r150+ + WebSocket + Node.js
// 基础场景搭建 - 工厂设备看板

import * as THREE from 'three';

// ==================== 场景初始化 ====================
// 创建场景、相机、渲染器三件套
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a2e);

const camera = new THREE.PerspectiveCamera(
    60, // 视场角
    window.innerWidth / window.innerHeight, // 宽高比
    0.1, // 近裁剪面
    1000 // 远裁剪面
);
camera.position.set(0, 5, 10);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
document.body.appendChild(renderer.domElement);

// ==================== 添加灯光 ====================
// 环境光让整体不至于全黑
const ambientLight = new THREE.AmbientLight(0x404040, 1.5);
scene.add(ambientLight);

// 主光源模拟车间顶灯
const mainLight = new THREE.DirectionalLight(0xffffff, 1.0);
mainLight.position.set(5, 10, 5);
scene.add(mainLight);

// ==================== 创建设备模型 ====================
// 这里用简单几何体模拟工厂设备,实际项目中替换为GLTF模型
const devices = [];

function createDevice(name, position) {
    // 底座
    const baseGeo = new THREE.BoxGeometry(1, 0.2, 1);
    const baseMat = new THREE.MeshStandardMaterial({ color: 0x333333 });
    const base = new THREE.Mesh(baseGeo, baseMat);

    // 机身
    const bodyGeo = new THREE.CylinderGeometry(0.3, 0.4, 1.5, 16);
    const bodyMat = new THREE.MeshStandardMaterial({
        color: 0x0088cc,
        metalness: 0.7,
        roughness: 0.3
    });
    const body = new THREE.Mesh(bodyGeo, bodyMat);
    body.position.y = 0.85;

    // 状态指示灯
    const lightGeo = new THREE.SphereGeometry(0.1, 16, 16);
    const lightMat = new THREE.MeshStandardMaterial({
        color: 0x00ff00,
        emissive: 0x00ff00,
        emissiveIntensity: 0.5
    });
    const statusLight = new THREE.Mesh(lightGeo, lightMat);
    statusLight.position.y = 1.7;

    // 组合为一个Group方便管理
    const group = new THREE.Group();
    group.add(base);
    group.add(body);
    group.add(statusLight);
    group.position.copy(position);

    // 保存引用,方便后续更新
    const deviceRef = {
        id: name,
        group: group,
        body: body,
        statusLight: statusLight,
        data: {
            temperature: 0,
            speed: 0,
            status: 'normal' // normal | warning | error
        }
    };

    scene.add(group);
    devices.push(deviceRef);
    return deviceRef;
}

// 创建三台设备
createDevice('CNC-01', new THREE.Vector3(-3, 0, 0));
createDevice('CNC-02', new THREE.Vector3(0, 0, 0));
createDevice('CNC-03', new THREE.Vector3(3, 0, 0));

// ==================== 动画循环 ====================
function animate() {
    requestAnimationFrame(animate);
    renderer.render(scene, camera);
}
animate();

这段代码创建了一个包含三台设备的场景,每台设备有底座、机身和状态灯。关键设计在于 deviceRef 这个对象——它保存了对 Three.js 对象的所有引用,后续更新数据时不需要重新创建模型,只需要修改现有对象的属性。

2.3 为什么不能每次数据变化都重建模型

刚接触Three.js的开发者,拿到新数据就喜欢 new THREE.Mesh() 创建新对象再 scene.add() 加进去。这样有两个大问题:第一,new 操作会触发垃圾回收,高频调用导致GC卡顿,整个页面会周期性卡一下;第二,每次替换场景对象会让Three.js重新编译着色器,这个开销不小。正确做法是预创建所有对象,之后只修改属性值。

三、数据同步方案对比

3.1 HTTP轮询:简单但延迟大

最原始的方案是前端定时请求接口:

// 技术栈:Three.js + WebSocket + Node.js
// HTTP轮询方案 - 延迟大,仅作对比参考

const POLL_INTERVAL = 500; // 每500ms轮询一次

async function pollData() {
    try {
        const response = await fetch('/api/devices/data');
        const data = await response.json();

        // 更新每台设备
        data.forEach(item => {
            const device = devices.find(d => d.id === item.id);
            if (device) {
                updateDevice(device, item);
            }
        });
    } catch (error) {
        console.error('轮询失败:', error);
    }
}

// 启动轮询
setInterval(pollData, POLL_INTERVAL);

轮询的问题很明显:最大延迟就是轮询间隔(500毫秒),每次请求都带HTTP头,带宽浪费严重。当设备数量上千时,500毫秒间隔根本不够用。

3.2 WebSocket:低延迟长连接方案

WebSocket建立一条全双工通道,服务器有数据就主动推送,前端不用轮询:

// 技术栈:Three.js + WebSocket + Node.js
// WebSocket客户端 - 实时接收设备数据

const wsUrl = `ws://${location.hostname}:8080/ws`;
let ws;

function connectWebSocket() {
    ws = new WebSocket(wsUrl);

    ws.onopen = () => {
        console.log('WebSocket已连接');
    };

    ws.onmessage = (event) => {
        const data = JSON.parse(event.data);

        // 根据消息类型处理
        if (data.type === 'device_update') {
            handleDeviceUpdate(data.payload);
        } else if (data.type === 'batch_update') {
            handleBatchUpdate(data.payload);
        }
    };

    ws.onclose = () => {
        console.log('WebSocket断开,3秒后重连');
        setTimeout(connectWebSocket, 3000);
    };

    ws.onerror = (error) => {
        console.error('WebSocket错误:', error);
    };
}

// 处理设备数据更新
function handleDeviceUpdate(payload) {
    const device = devices.find(d => d.id === payload.id);
    if (device) {
        updateDevice(device, payload);
    }
}

// 处理批量更新
function handleBatchUpdate(payloadArray) {
    payloadArray.forEach(item => {
        const device = devices.find(d => d.id === item.id);
        if (device) {
            updateDevice(device, item);
        }
    });
}

connectWebSocket();

3.3 Node.js端WebSocket服务

后端用Express加ws搭建WebSocket服务器:

// 技术栈:Three.js + WebSocket + Node.js
// 服务端WebSocket - 设备数据推送

const express = require('express');
const http = require('http');
const WebSocket = require('ws');

const app = express();
const server = http.createServer(app);
const wss = new WebSocket.Server({ server, path: '/ws' });

// 存储所有已连接的客户端
const clients = new Set();

wss.on('connection', (ws) => {
    clients.add(ws);
    console.log(`客户端连接,当前在线: ${clients.size}`);

    ws.on('close', () => {
        clients.delete(ws);
        console.log(`客户端断开,当前在线: ${clients.size}`);
    });
});

// 向所有客户端广播数据
function broadcastData(message) {
    const json = JSON.stringify(message);
    clients.forEach(client => {
        if (client.readyState === WebSocket.OPEN) {
            client.send(json);
        }
    });
}

// 模拟设备数据更新(实际项目中从数据库或MQTT获取)
function simulateDeviceUpdate() {
    const deviceIds = ['CNC-01', 'CNC-02', 'CNC-03'];
    const statuses = ['normal', 'warning', 'error'];

    deviceIds.forEach(id => {
        const payload = {
            id: id,
            temperature: Math.random() * 80 + 20, // 20到100度
            speed: Math.random() * 3000, // 0到3000转每分
            status: statuses[Math.floor(Math.random() * 3)]
        };

        broadcastData({
            type: 'device_update',
            payload: payload
        });
    });
}

// 每200ms模拟一次数据更新
setInterval(simulateDeviceUpdate, 200);

server.listen(8080, () => {
    console.log('服务启动在8080端口');
});

3.4 批量更新减少消息开销

当设备数量很多时,每条数据单独发一条WebSocket消息,消息头的开销反而比数据本身还大。更好的做法是攒一批再发:

// 技术栈:Three.js + WebSocket + Node.js
// 批量更新策略 - 攒够一批或超时就发送

const BATCH_SIZE = 10; // 每批最多10条
const BATCH_INTERVAL = 100; // 最大等待100ms

let batchBuffer = [];
let batchTimer = null;

function addToBatch(payload) {
    batchBuffer.push(payload);

    // 如果攒够了,立即发送
    if (batchBuffer.length >= BATCH_SIZE) {
        flushBatch();
        return;
    }

    // 如果还没攒够,设置定时器(重置计时器)
    if (batchTimer) {
        clearTimeout(batchTimer);
    }
    batchTimer = setTimeout(() => {
        flushBatch();
    }, BATCH_INTERVAL);
}

function flushBatch() {
    if (batchBuffer.length === 0) return;

    const message = {
        type: 'batch_update',
        payload: batchBuffer
    };

    broadcastData(message);
    batchBuffer = [];

    if (batchTimer) {
        clearTimeout(batchTimer);
        batchTimer = null;
    }
}

// 使用示例:设备数据变化时调用
// addToBatch({ id: 'CNC-01', temperature: 75.2, status: 'normal' });

四、前端渲染优化

4.1 更新函数设计与数据缓冲

收到数据后不要直接在 onmessage 里改Three.js对象,而是先缓存到缓冲区,然后在 requestAnimationFrame 回调里统一处理:

// 技术栈:Three.js + WebSocket + Node.js
// 前端渲染优化 - 数据缓冲 + 帧率同步更新

// ==================== 数据缓冲区 ====================
// WebSocket收到的数据先存这里,等渲染帧再处理
const pendingUpdates = [];

// 覆盖之前的handleDeviceUpdate
function handleDeviceUpdate(payload) {
    pendingUpdates.push(payload);
}

function handleBatchUpdate(payloadArray) {
    pendingUpdates.push(...payloadArray);
}

// ==================== 设备更新逻辑 ====================
function updateDevice(device, data) {
    // 更新数据缓存
    device.data = { ...device.data, ...data };

    // 根据温度调整机身颜色
    const temp = data.temperature;
    if (temp > 90) {
        device.body.material.color.setHex(0xff3333); // 红色表示过热
        device.body.material.emissive.setHex(0xff0000);
        device.body.material.emissiveIntensity = 0.3;
    } else if (temp > 70) {
        device.body.material.color.setHex(0xffaa00); // 黄色表示警告
        device.body.material.emissive.setHex(0xffaa00);
        device.body.material.emissiveIntensity = 0.2;
    } else {
        device.body.material.color.setHex(0x0088cc); // 蓝色表示正常
        device.body.material.emissive.setHex(0x000000);
        device.body.material.emissiveIntensity = 0;
    }

    // 根据状态更新指示灯颜色
    let lightColor;
    switch (data.status) {
        case 'normal':
            lightColor = 0x00ff00; // 绿色
            break;
        case 'warning':
            lightColor = 0xffaa00; // 黄色
            break;
        case 'error':
            lightColor = 0xff0000; // 红色
            break;
        default:
            lightColor = 0x00ff00;
    }
    device.statusLight.material.color.setHex(lightColor);
    device.statusLight.material.emissive.setHex(lightColor);

    // 转速可视化:让机身旋转速度随转速变化
    device.rotationSpeed = data.speed / 3000; // 归一化到0到1
}

// ==================== 渲染循环(优化版) ====================
let lastFrameTime = 0;

function animate(time) {
    requestAnimationFrame(animate);

    // 计算帧间隔,防止高频更新
    const delta = time - lastFrameTime;
    if (delta < 16) { // 约60fps
        return;
    }
    lastFrameTime = time;

    // 处理缓冲区中的数据
    if (pendingUpdates.length > 0) {
        pendingUpdates.forEach(update => {
            const device = devices.find(d => d.id === update.id);
            if (device) {
                updateDevice(device, update);
            }
        });
        pendingUpdates.length = 0; // 清空缓冲区
    }

    // 让设备机身旋转(模拟运转)
    devices.forEach(device => {
        if (device.rotationSpeed !== undefined) {
            device.group.rotation.y += device.rotationSpeed * 0.05;
        }
    });

    renderer.render(scene, camera);
}

requestAnimationFrame(animate);

这段代码的关键在于 pendingUpdates 缓冲区。WebSocket消息可能在渲染帧之间随时到达,如果直接在 onmessage 里修改Three.js对象,会导致渲染过程中修改场景,引起各种诡异问题。把数据攒起来,在 requestAnimationFrame 回调开头统一处理,既保证了渲染一致性,又避免了多余重绘。

4.2 实例化渲染处理大量设备

当场景中设备数量达到上千甚至上万时,每台设备都创建独立的Group和Mesh,性能会急剧下降。Three.js提供了 InstancedMesh,可以把上千个相同模型合并为一次Draw Call:

// 技术栈:Three.js + WebSocket + Node.js
// 实例化渲染 - 上千设备性能优化

const INSTANCE_COUNT = 2000; // 2000台设备

// 创建实例化网格
const instanceGeo = new THREE.BoxGeometry(0.5, 1, 0.5);
const instanceMat = new THREE.MeshStandardMaterial({
    color: 0xffffff,
    metalness: 0.5,
    roughness: 0.5
});
const instancedMesh = new THREE.InstancedMesh(
    instanceGeo,
    instanceMat,
    INSTANCE_COUNT
);

// 为每个实例设置位置和颜色
const dummy = new THREE.Object3D();
const instanceColors = new Float32Array(INSTANCE_COUNT * 3);

for (let i = 0; i < INSTANCE_COUNT; i++) {
    // 网格布局:每行50个,共40行
    const row = Math.floor(i / 50);
    const col = i % 50;

    dummy.position.set(col * 0.8 - 20, 0, row * 0.8 - 16);
    dummy.updateMatrix();
    instancedMesh.setMatrixAt(i, dummy.matrix);

    // 初始颜色:蓝色
    instanceColors[i * 3] = 0;
    instanceColors[i * 3 + 1] = 0.5;
    instanceColors[i * 3 + 2] = 0.8;
}

instancedMesh.instanceColor = instanceColors;
scene.add(instancedMesh);

// 更新单个实例颜色(比如设备温度变化)
function updateInstanceColor(index, r, g, b) {
    instanceColors[index * 3] = r;
    instanceColors[index * 3 + 1] = g;
    instanceColors[index * 3 + 2] = b;
    instancedMesh.instanceColor.needsUpdate = true;
}

// 更新单个实例位置(比如设备移动)
function updateInstancePosition(index, x, y, z) {
    dummy.position.set(x, y, z);
    dummy.updateMatrix();
    instancedMesh.setMatrixAt(index, dummy.matrix);
    instancedMesh.instanceMatrix.needsUpdate = true;
}

使用 InstancedMesh 后,2000台设备的Draw Call只有1次,相比每台一个Mesh的2000次Draw Call,性能提升是数量级的。

4.3 差量同步减少传输量

当设备数据变化不大时(比如温度只变了0.5度),没必要把完整数据都传一遍。差量同步只传变化的字段:

// 技术栈:Three.js + WebSocket + Node.js
// 差量同步 - 只传变化的字段

// 服务端:记录上次发送的数据,只发差量
const lastSentData = new Map(); // deviceId 映射到上次发送的数据

function sendDiffUpdate(deviceId, newData) {
    const lastData = lastSentData.get(deviceId);

    // 计算差量
    const diff = {};
    let hasChange = false;

    Object.keys(newData).forEach(key => {
        if (!lastData || newData[key] !== lastData[key]) {
            diff[key] = newData[key];
            hasChange = true;
        }
    });

    // 没有变化就不发送
    if (!hasChange) return;

    // 记录本次发送
    lastSentData.set(deviceId, newData);

    // 发送差量
    broadcastData({
        type: 'device_diff_update',
        payload: {
            id: deviceId,
            changes: diff
        }
    });
}

// 前端处理差量更新
function handleDiffUpdate(payload) {
    const { id, changes } = payload;
    const device = devices.find(d => d.id === id);
    if (device) {
        // 只更新变化的字段
        Object.keys(changes).forEach(key => {
            device.data[key] = changes[key];
        });
        // 根据变化的字段做局部更新
        applyVisualUpdate(device, changes);
    }
}

function applyVisualUpdate(device, changes) {
    // 只更新受影响的视觉元素
    if (changes.temperature !== undefined) {
        updateTemperatureVisual(device, changes.temperature);
    }
    if (changes.status !== undefined) {
        updateStatusLightVisual(device, changes.status);
    }
    if (changes.speed !== undefined) {
        updateSpeedVisual(device, changes.speed);
    }
}

五、应用场景分析

5.1 工业制造场景

工厂车间的数字孪生是最典型的应用。车间里有几十到几百台CNC机床,每台机器的温度、转速、运行状态需要实时反映到3D场景中。操作工在中控室看到画面,就能判断哪台设备异常,提前处理。延迟控制在200毫秒以内是基本线,超过500毫秒操作工就会觉得画面跟不上现场。

5.2 智慧园区场景

园区数字孪生要同时展示建筑、道路、车辆、人员、环境监测等多个维度的数据。数据源更加分散——车辆GPS轨迹、停车场车位状态、电梯运行数据、空调能耗等等。这个场景的关键在于把不同频率的数据源做好聚合,不要把所有数据都按最高频率推送。

5.3 电力监控场景

变电站的数字孪生对实时性要求更高。电压、电流、温度等参数每秒可能变化多次,而且变化幅度很小但需要精确反映。这个场景用差量同步特别合适——大部分时候数值只变化零点几,把完整数据传过来太浪费带宽。

六、技术优缺点分析

6.1 WebSocket方案的优点

WebSocket是全双工连接,服务器主动推送数据,前端不用轮询,延迟可以控制在几十毫秒以内。一条连接能持续复用,不像HTTP请求那样每次都要建立新连接,握手开销可以忽略不计。带宽效率也高,不需要每次请求都带HTTP头。

6.2 WebSocket方案的缺点

WebSocket是长连接,在Nginx或负载均衡器后面需要额外配置。如果代理服务器设置了较短的空闲超时(比如60秒),连接会被断开,需要心跳保活机制。另外,WebSocket不支持HTTP缓存和CDN加速,对于需要跨越多层代理网络的环境(比如一些政府或军工项目),可能需要额外的网络配置。

6.3 数据缓冲策略的优点

在渲染帧中统一处理数据更新,保证了渲染一致性。不会出现这种问题:一帧里先渲染了一半设备,然后收到新数据修改了设备状态,剩下的设备用了新状态渲染,导致画面里一半设备是旧状态一半是新状态。

6.4 数据缓冲策略的缺点

缓冲区引入了额外延迟——数据要等到下一个渲染帧才被处理。不过在60fps下这个延迟只有约16毫秒,几乎可以忽略。真正的风险是如果某个渲染帧卡住了(比如复杂场景首帧加载),缓冲区里的数据会堆积,恢复后一次性处理大量数据可能导致抖动。解决办法是设置缓冲区上限,超过上限就丢弃最旧的数据。

七、注意事项

7.1 心跳保活机制

WebSocket连接空闲时可能被中间的代理服务器或防火墙断开。服务端需要定期发送心跳包(ping),前端收到后回复(pong),证明连接还活着。Node.js的ws库内置了心跳支持:

// 技术栈:Three.js + WebSocket + Node.js
// WebSocket心跳保活

wss.on('connection', (ws) => {
    let isAlive = true;

    ws.on('message', (message) => {
        // 收到心跳回复
        if (message === 'pong') {
            isAlive = true;
        }
    });

    ws.on('close', () => {
        clients.delete(ws);
    });

    // 发送心跳
    ws.on('ping', () => {
        isAlive = true;
    });
});

// 每30秒检查一次心跳
setInterval(() => {
    wss.clients.forEach((ws) => {
        if (!isAlive) {
            ws.terminate();
            return;
        }
        isAlive = false;
        ws.ping();
    });
}, 30000);

前端也需要处理心跳:

// 技术栈:Three.js + WebSocket + Node.js
// 前端心跳回复

function setupHeartbeat(ws) {
    ws.onmessage = (event) => {
        // 服务端发来的ping消息
        if (event.data === 'ping') {
            ws.send('pong');
            return;
        }
        const data = JSON.parse(event.data);
        // 业务处理逻辑
        if (data.type === 'device_update') {
            handleDeviceUpdate(data.payload);
        }
    };
}

7.2 消息丢失处理

网络不稳定时WebSocket消息可能丢失。关键数据需要可靠的传输保障。可以在应用层实现序列号机制:

// 技术栈:Three.js + WebSocket + Node.js
// 消息序列号 - 检测丢失消息

let serverSeq = 0; // 服务端序列号

function broadcastData(message) {
    serverSeq++;
    message.seq = serverSeq; // 附带序列号

    const json = JSON.stringify(message);
    clients.forEach(client => {
        if (client.readyState === WebSocket.OPEN) {
            client.send(json);
        }
    });
}

// 前端检测丢失
let lastReceivedSeq = 0;

ws.onmessage = (event) => {
    const data = JSON.parse(event.data);
    if (data.seq) {
        if (data.seq - lastReceivedSeq > 1) {
            console.warn(
                `检测到消息丢失: 期望${lastReceivedSeq + 1},收到${data.seq}`
            );
            // 请求补发丢失的消息
            ws.send(JSON.stringify({
                type: 'resync',
                from: lastReceivedSeq + 1
            }));
        }
        lastReceivedSeq = data.seq;
    }
    // 业务处理逻辑
};

7.3 大数据量时的性能保护

当设备数据变化过于频繁时(比如传感器每秒发100条数据),前端渲染可能跟不上。需要做限流:

// 技术栈:Three.js + WebSocket + Node.js
// 前端限流 - 防止渲染过载

const MAX_PENDINGS = 500; // 缓冲区最大容量

function addToBuffer(payload) {
    if (pendingUpdates.length >= MAX_PENDINGS) {
        // 缓冲区满了,丢弃最旧的数据
        pendingUpdates.shift();
    }
    pendingUpdates.push(payload);
}

// 或者:降频处理,每隔固定时间才处理一次缓冲区
const MAX_UPDATES_PER_FRAME = 50; // 每帧最多处理50条更新

function processBuffer() {
    let processed = 0;
    while (
        pendingUpdates.length > 0 &&
        processed < MAX_UPDATES_PER_FRAME
    ) {
        const update = pendingUpdates.shift();
        const device = devices.find(d => d.id === update.id);
        if (device) {
            updateDevice(device, update);
        }
        processed++;
    }
}

八、文章总结

数字孪生场景的数据同步延迟问题,核心在于三个环节的优化:传输层用WebSocket替代HTTP轮询,把数据从后台推过来的延迟从几百毫秒降到几十毫秒;应用层用差量同步和批量更新减少网络开销,让带宽不浪费在不必要的重复传输上;渲染层用缓冲区加帧同步的方式更新Three.js场景,保证渲染一致性的同时避免性能抖动。

对于设备数量少(几十台)的场景,WebSocket加直接更新就够了。设备数量中等(几百到上千台)时,加上批量更新和差量同步。设备数量大(上千到上万台)时,必须用实例化渲染,配合限流策略保护性能。

实际项目中,建议先把完整链路跑通,用Chrome DevTools的Performance面板测量真实延迟,找到瓶颈后再针对性优化。不要一上来就过度优化,先确保功能正确,再追求性能。