前端可视化库的设计技术及思想

0 阅读25分钟

那些将数据、信息用视图形象展示出来的技术都叫可视化。前端常见的可视化场景有:数据图表、地图、关系图、3维园区、文本编辑,对应的库则有:echarts、openLayer、antv、three.js等等(音视频解码播放、游戏、ui库、框架不属于可视化范畴但也有使用到可视化技术)

得益于现在开源的可视化库一大堆,我们几乎不用自己从头写了,但基于可视化库做业务开发还是会遇到不少问题。我想,梳理一下这类库的设计特点,其中一些思想也能应用到可视化业务的开发中,有所益处。

1. 可视化库的常见问题

每个可视化场景都会有自己的需求,这些需求又分化出许多功能。比如在界面上绘制1个设备,用图片还是用代码绘制,选中时用框选还是变色。我们就把这种具体功能的实现问题称做第一类问题

功能实现的过程中我们可能还会想,要不要数据单独放一边,逻辑单独放一边,开发就可以标准化一些,以后代码量增多了,调试、查找起来也能较为方便。这一类可以算作是维护的问题,属于软件开发中都比较普遍的问题

另一个比较普遍的问题是灵活性和易用性的问题。一个复杂的功能要做得易用,就意味着需要更多的提前封装。而封装得太深,里面一些细节的地方通常是很难支持外面再去做控制的。所以易用和灵活存在较强的对立关系

除以上3类问题外,可视化场景比其它应用场景因为多出来一个“视图”,所以常多出来一个视图卡顿的问题,这种卡顿可能有以下三个方面原因:

  • 数据多,处理耗时,又是处理完之后才触发渲染,视图出来的自然就慢。
  • 渲染的图形元素多,这中间还涉及到一些属性计算,视图出现明显卡顿。
  • 也有可能是数据处理、渲染过程中发生了交互事件,当前程序执行完成后才能响应交互事件,用户就会感觉到卡顿。

不同的可视化场景,在数据、视图、交互方面会有不同偏向,视图卡顿的主要原因也就不一样。

  • 科学计算、数据图表可能要处理的数据量大,主要是数据处理部分慢;
  • 3D场景视图计算多、渲染量大;
  • 一些标绘场景又是交互比较频繁;
  • 以上3种也可能同时出现;

视图卡顿问题其实可以算做性能问题,只不过表现在视图方面。它和以上几类问题之间都存在关系,而且会互相影响

你可能会因为视图卡顿而再写一套更新逻辑,只更新那些变化的元素;或者是因为1个功能使用起来不方便,引入更多的代码来支持。这样做都是引入了新的代码,必定导致后续维护难度增加。你可能又为了方便维护,要修改一下开发标准(功能的实现流程、新的规范等),如果方式不当的话又可能导致某个功能调用链过长、要查询更多数据,反向造成性能方面的困境

这3类问题其实都是在解决第一类问题(功能实现)的过程中产生的。但是在处理这3类问题过程中也可能再导致第一类问题(某个功能的实现不准确),所以它们完全是互相影响着的,或者说问题本身不会消失,它只是在各方面转移

这样看来我们似乎并不能解决所有问题,甚至要解决其中几个都很困难。不过好在我们对维护难度、性能高低这些有不同的容忍程度,只要各方面问题不超出对应的那个程度就还是可以接受的。

其中第一类问题是我们容忍程度最低的,通常都需要我们实现全部功能,且不能有偏差。而维护方面的问题,我们的容忍度最高,因为它不是使用过程中的问题。其它问题则看各自的应用场景决定。

按照这个容忍程度排序,我们就有了一个大致的设计方向,即在解决第一类问题的前提下再去解决其它问题,设计过程中发现冲突的话按照这个容忍程度的排序进行问题的转移(一般最后都被转移到维护方面的问题上)

第一类问题是各可视化库独有的问题,情况很多,所以这里我们也列不出它们的解决方式。下面涉及的设计也多是维护、灵活与易用、性能三方面的问题相关的。(其余的其实还有稳定性、内存占用大小等方面的问题,但不主要,并未列出)

2. 渲染方式

普通dom渲染:普通的html元素操作方便,但很难绘制特殊的图形。渲染要求不高的场景可以考虑(如文本编辑、代码编辑、任务进度)

svg渲染:比普通dom更强大的图形绘制,兼具易操、打印和导出,移动端设备显示比canvas更稳定。但图形元素很多时容易卡顿。渲染量不是较大的场景都可以考虑使用。

canvas(2D/webgl/webgpu)渲染:渲染效果和性能最好,大数据量渲染、复杂场景必备。只是实现和管理方面比较麻烦。

多数前端的可视化库中,都是用多种渲染方式。像标题、hover提示、右键菜单、控件这一类ui元素,与主视图的逻辑、渲染关联不强,比较独立,多是用普通dom实现。主视图渲染也可能会支持多渲染方式,供开发者在不同的需求环境中切换使用。

混合渲染:主视图使用svg/dom,内部图形可用svg元素渲染,或者foreReginObject元素中嵌套普通dom、canvas来渲染。这种方式属于以上3种渲染方式的折中使用,通常是用在报表配置、深入结合了具体业务这些场景。如果各图类差异大,图形间关联比较弱时可以考虑使用。

3. 分层设计

可视化中的许多功能,在实现的时候都会用到一些普遍的技术,比如:多数功能都涉及到各自的数据、要展示出不同的视图效果,有各自的交互控制

这些功能的执行方向也比较固定,可能都是从数据到视图,或者发生交互到修改数据。把各功能中数据、视图这些普遍性的单独抽象出来,然后每个功能的实现都分散到这些抽象体当中,执行功能也是按固定的顺序执行(一个抽象体到另一个抽象体)。

对数据、视图这些的抽象就像是楼栋中的楼层,功能的执行方向则是电梯的运行方向。分层不仅是分化出来好管理,而且还固定了功能的执行方向固定则意味着好标准化,容易维护

3.1 经典的MVC分层

MVC.png

Model(模型层)放置原始数据或中间生成的数据,数据的获取和设置一般通过定义的接口来管理。模型层不主动调用视图层和控制层的接口

View(视图层)进行直接与渲染相关的操作,或者是对视图描述。数据可以从模型层取,可以从控制层传来。但只是被动渲染不修改模型层数据,也不调用控制层操作

Controller(控制层)用来协调View和Model,作为两者之间的映射。一个暴露的接口通常就是一个功能的实现(调用Model层和View层接口)。监听视图层事件,事件处理逻辑都放在这一层。

MVC的这种分层非常契合可视化功能中的数据、视图、交互3要素,现代的可视化的库几乎都有使用这种分层方式。

简单的MVC示例代码(线图和树图绘制):

// 模型层
class Model {
    constructor(){
        // 存放的数据
        this.lineData = {};
        this.treeData = {};
    }
    getData(){ ... }
    setData(){ ... }
}
// 视图层
class View {
    // 传入 Model层实例
    constructor(model){   this.model = model;  }
    drawLine(){ ... }
    drawTree(){ ... }
}
// 控制层
class Controller{
    // Model层和view层实例
    constructor(model,view){
        this.model = model;
        this.view = view;
    }
    render(option){
        this.model.setData(option.data); // 设置 线条、树 所用的数据
        ... 其它映射逻辑
        this.view.drawLine();
        this.view.drawTree();
    }
}
// =====使用入口=====
class Mvc {
    constructor(){
        this.model = new Model();
        this.view = new View(this.model);
        this.controller = new Controller(this.model,this.view);
    }
    getData(){ return this.model.getData(); }
    setOption(option){
        this._option = option;
        this.controller.render(option);
    }
}

这样,在增加一个新功能时,就在Model层放置该功能用到的数据,View层实现对应图形的绘制,Controller层做两者的映射,管理起来比较方便。

当然,MVC也有它的缺点——分层较粗糙,应用体系较大时各层显得臃肿,各层之间的边界模糊。所以我们常见到的可视化库设计也不完全只有MVC分层。

3.2 其它分层

MVPMVVM属于MVC的变体,也专用于带视图的开发设计中,但在可视化场景中使用却不常见

其中MVP是把View层多数的逻辑都抽离了,几乎都留在了PPresenter)层,View层也不能直接访问Model层,数据都是从P层传过来,失去了很多灵活性,这导致Presenter层要为交互定义很多的接口来维护与view层的通信,非常繁琐

MVVM在UI框架中使用多,但可视化中视图的更新逻辑往往很复杂,不仅是数据变化,还包括过渡动画、图形重绘等。将这些复杂逻辑都塞进ViewModel,容易使其变得臃肿、难以维护。

视具体情况,还可以再分出其它的层。比如Model层中,数据可能来自网络请求、本地缓存、中间生成,这种数据的获取就可以考虑抽象为一个数据访问层。一些科学计算的场景基本都要对数据做处理,可以考虑抽象出一个数据处理层。这两种在后端架构里更常见。

另外可视化库应用到具体业务场景时,功能通常是具体的业务功能,可以考虑在Model层和Controller层之间放一个业务层。视图层太大也可以考虑再分化出一个渲染层

3.3 渲染层

无论什么可视化场景,绘制的总是图片、点、线、几何图形(少数是像素级的控制),或者是它们组合出来的复杂图形图像。又上面渲染方式里说了,支持多种渲染方式可适应不同的场景。例如小场景用svg渲染,大场景切换canvas 2D渲染。或者是用webgl渲染,环境对webgl支持较差时用canvas 2D做降级方案。

基于以上两点,将它们从View层分离出来作为1个渲染层是比较合理的,View层则可以做一些复杂视图的描述(比如一个树图有哪些基本几何图形,位置关系等),和对渲染层的具体调用。

以下是一个可切换svg/canvas 2D的渲染层组成示例:

  • path元素:path可以绘制圆、矩形、椭圆等其它几何图形,统一用path绘制方便管理。通常将path抽象为1个基类,其它几何元素继承使用。
  • Group分组:用于添加子图形或子分组,但本身属于元素,只是不绘制出图形。像svg中的<g>一样。可使用组合模式实现,对Group进行位移、缩放等更新时,Group递归自身子元素调用相同接口进行位移、缩放操作。
  • Layer分层:每个分层是一个svg或canvas元素,更新较频繁的单独放一层,既方便管理也用于节省渲染开销。
  • canvas渲染器和svg渲染器:管理各Layer,包含具体的渲染逻辑实现,各渲染器暴露相同的绘制接口。
  • Animation动画:动画的启、停、插帧,多动画的管理。
  • 事件响应:每帧的更新事件、元素和层级的事件等等。

4. 模块化设计

分层是对功能执行过程进行划分,模块则是对单个功能的划分。一个功能可能会包含多个小功能,一个模块也就可能包含多个小模块。模块和分层可以结合使用,一个模块中可做几个分层。层里面也可以再分化出模块,两者互相交叠、嵌套,可以组成很复杂的结构。这在系统架构里面就比较明显,系统架构图表现的也就是这种关系。

Model-mvc.png 分层一般不会太多,一个大的可视化库 通常就3~5层,层之间的调用关系较固定。模块则根据功能的数量,很容易抽出大量模块,而且模块间的依赖、调用关系也不那么固定

场景中有一些较为直观的,能看到的,较容易被我们模块化出来:

  • 工作区/场景:整个可视化容器的管理,一般就作为主实例使用。
  • 图形/图例:场景中绘制的图形、节点、折线图视图等单独抽象出来。
  • 控件、工具栏、菜单这些辅助性工具。

另一类则是要通过思考才能抓取到它们的特征,抽象出来,通常不容易想到。比如:

  • 调度器:控制任务执行顺序,安排优先级。
  • 拖动、鼠标绘制等控制功能。
  • 资源管理器:文件、图片等数据的加载、缓存、清除。
let tree = new Enter.Tree(配置数据);
let line = new Enter.Line(配置数据);
// 场景容器
let work = Enter.Workspace();
work.add(tree);
work.add(line);

抽取模块之后,在使用时变得很灵活、直观,还有就是方便维护。

5. 分层和分块的问题

分层和模块划分看似能让我们的开发有序地进行了,但复杂到一定程度后许多问题就突显出来:

  • 过多的模块间通信都要实现对应的接口,维护成本高。
  • 模块或层之间通信带来额外的调用开销,数据在其间传递可能产生复制成本。
  • 许多简单的功能,执行时也需要经过所有层次,效率低下。

循环引用的问题:模块和分层的设计中都会有一个模块调用另一个模块,一个层调用另一个层的方法的情况,因此在创建实例时会保留一个引用,如下:

class Workspace {
    nodes = [node1,node2,..];   // 存了子节点列表
    addNode(node){  
        node._workspace = this; // 子节点上挂载 App实例。
        this.nodes.push(node);
    }
}
class Tree {
    attr(key,val){
        this.setAttr(key,val);
        this._workspace.render();// 调用了主实例方法
    }
}

以上两个模块存在互相引用的关系,如果两个模块互相调用对方的方法,且进一步引发对方更新,这很可能就会出现死循环的情况。复杂场景的模块划分很多,这种互相依赖形成一个环形的问题更难发现。

除了在执行过程中可能出现死循环外,开发过程中也可能出现。某个模块逻辑或接口做调整,与其牵涉的模块多半也要改动,糟糕的时候就会一级一级地影响下去。

上面的块/层间通信开销大、维护耗时、循环引用这些问题在复杂的应用程序里是无法避免的,只能尽量减轻,死循环这种严重问题则最好不要出现。

关系管理:无论是模块还是层,分化出来后,它们就处在各种各样的关系之中。最容易出问题,最难管理的也是它们的关系问题。

凡是问题都涉及到两方面,这里则是模块/层次太多和我们未能很好的管理这些模块和层级间的关系。这也衍生出两个方面的解决思路,一是考虑合并部分模块或层,删去不合理的功能(技术之外的方法)。 另一方面是增强管理,可以如下考虑:

  • 精准的抽象一个模块/层:如果把不属于该模块/层的功能抽象进去,在调用关系上就容易混乱;
  • 关系尽量保持单向:模块/层之间的调用尽量是单向的,减少互相影响。
  • 循环引用时避免调用的接口中出现对其它模块/层再做修改、更新这样的操作。

6. 驱动型设计

分层和模块化更偏向于静态的应用程序的管理方面。驱动型设计则偏向于像动态的程序的执行方面。

数据驱动数据发生变化就去渲染。通常是Controller层整合相应的数据变动,Model层合并新旧数据,视图层监听Model变化,进行渲染更新。这种驱动方式很适合可视化的“数据到视图”的特点,使用起来也很简便。但存在以下问题,通常就中小场景考虑。

  • 为了精准的描述视图要存许多额外状态。
  • 复杂的图形效果和行为也很难仅用数据描述实现。
  • 只更新其中一个值时,也会进行整个数据的合并更新。

事件驱动:在交互性强的场景(用户的交互、系统应用的交互),事件驱动的方式执行是比较方便的。可视化场景的事件主要是用户触发的鼠标/键盘事件,应用内也会自定义事件来进行模块或层之间的解耦。触发对应事件时,执行预先定义好的逻辑就行。只是事件流动混乱、事件间执行无顺序,难预测,不好调试

状态驱动:程序应用多需要自己上的执行规则,不同情况下相同的输入执行的结果会不一样。可视化里比较常见的是编辑和查看这两种状态下交互操作会不一样。

  • 可以通过定义状态状态的转移、触发转移的条件转移的规则来刻画这些行为。
  • 应用每个时刻都处在一个确定的状态中,各状态下可接收的输入/事件不一样。状态的转移则由触发的事件/条件当前的状态决定,然后执行对应的逻辑,再切换状态。
  • 状态少可以考虑用简单的分支条件方式实现,或使用状态模式设计。
  • 状态管理复杂时可以使用有限状态机的设计方式实现。

指令驱动:像jquery那样,封装成很细小的功能接口,每个接口几乎只实现一个单一作用,可链式调用。大的功能逻辑完全由开发者自行组合实现。如果要追求极大的灵活性的话可以考虑使用。

结合使用:以上的几种驱动方式并不冲突,实际中也常是结合使用。比如用数据驱动方式绘制视图,又留一些接口可单独设置视图,事件监听响应用户操作等等。

7. 事件系统

也就是事件驱动的设计。可视化库中,事件的设计几乎是避不了的,可考虑如下设计:

使用观察者模式:一个观察者列表,发布事件时直接遍历列表中的观察者,传入事件类型和数据。这种实现耦合较高,直接控制,实现简单。适用于小范围的,或关系明确的对象间通信,如GUI事件监听、响应式数据绑定。

使用发布订阅模式:只监听自己想要的事件,事件发布方法通常向外暴露,所以事件监听者和执行者互相不知道对方的存在,耦合低。单个事件的监听者较多时,可考虑异步执行每个监听函数。更适合跨模块、系统这种范围大的情况

许多模块、层都要维护自己的事件,一般事件管理单独实现作为一个基类使用。

// 事件类
class Event {
    on(eventName,fn){ }
    publish(eventName, data){ }
    off(eventName,fn){}
}
// 其它类继承使用
class Workspace extends Event {}

使用原生的dom事件:对于使用 普通dom/svg渲染的可视化库,可直接用 原生的事件系统。自定义类型事件可用 CustomEvent扩展。

事件类型、数据格式的标准化、特殊事件的节流、防重复触发这些也是需要的。

8. 渲染模式

即时渲染和延迟渲染最早是来自计算机图形学的两种概念,本质都是为了快速渲染,在不同情况下考虑使用。

即时渲染:‌执行绘制时,直接计算相关图形属性,然后渲染显示。特点就是简单直接

  • 初始渲染和更新视图几乎用同一套逻辑接口。更新操作时,直接清空所有视图全部进行重绘
  • 这样做无需进行数据对比,不需要保存多少数据状态,而且只用维护一套逻辑。
  • 整个过程同步执行,不用担心中途插进一个异步任务来降低实时性。

这种追求实时渲染而使用简单逻辑的方式,在复杂场景反而不能实时。数据多,处理慢,或视图元素多的时候,全部重绘容易出现一瞬的空白。因为是同步执行的,只要某一次执行耗时,期间用户发生了交互,就不能及时响应。因为保存的状态少,所以动画效果很难支持,整个体验过程比较生硬

对以上问题,可以适当做一些状态保留,再写一套更新逻辑,只重绘有变化的图形。可以将更多控制逻辑交给开发者,让其自行优化。或者你也可以考虑用下面这种模式。

延迟渲染:‌针对复杂场景、大数据量情况比即时渲染更快。

  • 触发交互时引入一定的延迟(<16ms),这可以剔除中那些无效的交互,只执行最后一次有效的事件。
  • 数据更新时,同样引入延迟,将多次变更的数据合并计算,可以省掉一些重复计算,最后一次才触发视图更新。
  • 保存更多的数据状态、元素状态,在更新时只重绘变动的部分。
  • 对任务(数据处理、视图设置等)进行调度异步执行,保证能及时响应用户的交互。

这种渲染模式和实时渲染几乎是相反的,要保存许多数据状态支持管理,架构也会变得比较复杂。在有些实时要求高的场景,还是不大适合,可能部分要考虑单独实现即时渲染方式。

一些可视化库也会提供两种渲染模式切换使用,小场景、实时性要求高的点用即时渲染,复杂场景切换为延迟渲染。

9. 调度

复杂场景数据多、更新频繁,短时间内要处理许多任务(数据处理、渲染、交互事件),不想卡顿就只能对任务异步执行,异步任务太多的话,它们的执行顺序不一定按预想的一样。调度则是一种管理任务执行顺序的一种技术,几乎都是与延迟渲染结合使用。

实现调度前要先进行以下设计:

  • 任务划分足得够小:js是单线程的,虽然对任务异步执行,但如果有一个任务很耗时,仍然要等该任务执行完毕才能响应鼠标事件和其它程序。所以要将一个大的任务划成多个足够小的任务,这得在编写程序时就做好预估。
  • 数据分片:面对大量的数据,即使一个不复杂的程序也会要执行很长时间。对于数据要选择能分批处理的方式,每个任务只处理一部分数据,保证每一个任务不会占用过多时间。
  • 给任务分优先级:定义哪些属于高优先级任务(交互事件、渲染任务),按优先级顺序排列执行。
  • 任务队列:任务按优先级放到队列中,执行完一个任务后出队列。通常分高优先级队列,低优先级队列。
  • 任务间依赖:一个系列任务有自己的执行顺序。每个任务用两个指针链接到前后任务,按此顺序添加到队列执行。
  • 任务间数据传递:多个任务共同完成一个数据处理时,常在最后一个任务才将数据更新到Model中,中间的产生的数据就需要传递。可以建立任务上下文(包含要调用的api、模型、输出的数据)。执行当前任务时获取上一个任务的输出数据。

以上设计好之后,就可以开始调度了。使用requestAnimationFrame,每一帧执行多个任务,超过指定时长(一般<16.6ms,超过会感到界面卡顿)就暂停执行后面的任务,在下一帧回调中再次执行。下面是个简易的调度示例:

// 任务的结构
Task { 
    id: string; 
    fn: () => void | Promise<void>; // 任务的具体执行函数
    priority: number; 
    output: any,// 输出数据存放
    head: Task, // 前置任务
    next: Task, // 下一个任务
    deadline?: number; // 截止时间戳 
}
// 每帧回调
scheduleFrame(){
    this.rafId = requestAnimationFrame((timestamp) => {
        // 高优先级任务处理
        this.processHighPriorityTasks(timestamp);
        // 低优先级任务使用 requestIdleCallback 
        if (typeof requestIdleCallback !== 'undefined') { 
            this.ricId = requestIdleCallback((deadline) => { 
                this.processLowPriorityTasks(deadline); 
            }, { timeout: 1000 }); 
        }else { 
            // requestIdleCallback 不可用则 降级处理:使用setTimeout 
            setTimeout(() => { 
                this.processLowPriorityTasks({ timeRemaining: () => 50, didTimeout: false }); 
            },0); 
        }
    });
    // 判断各任务队列中是否还有任务
    if (!this.highPriorityQueue.isEmpty() || !this.lowPriorityQueue.isEmpty()) { 
        this.scheduleNextFrame(); 
    }
}
// 高优先级队列处理
processHighPriorityTasks(timestamp) {
    const frameStart = performance.now();
    // 超过指定时间就停止执行,避免一帧耗时太久产生卡顿
    while((!this.highPriorityQueue.isEmpty() && performance.now() - frameStart) < 12){
        // 出队,执行任务
        const taskItem = this.lowPriorityQueue.dequeue(); 
        this.executeTask(taskItem.task);
    }
}
// 低优先级队列处理
processLowPriorityTasks(deadline) {
    // 任务不为空,空闲时间>0 或者 未超时 就执行
    while(!this.lowPriorityQueue.isEmpty() && 
    (deadline.timeRemaining() > 0 || deadline.didTimeout)){
        const taskItem = this.lowPriorityQueue.dequeue(); 
        this.executeTask(taskItem.task);
    }
}

一帧执行结束后,浏览器会响应刚才发生的交互事件,这些交互中产生的新任务被添加到队列中,下一帧再次按优先级执行。

更大的数据量或者不能分片的数据处理可以考虑结合Worker使用。

10. 扩展和插件

很多可视化库支持扩展,这让开发人员可以开发出自己需要的功能,还方便打造自己的社区。另外,它本有的一些功能也当成扩展独立了出去,各使用场景可以按自己的需求添加,减少了不必要的包,增加了灵活性。

要支持扩展,一般是要在核心部分较为标准化之后考虑。哪些是核心,哪些又可以扩做成展呢? 功能层面上来说就是数据到视图,加上必要的交互。程序上则是数据、视图、交互涉及到的基础的模块和层,以及它们之间的关系,其它的则都可以考虑做成扩展使用。

扩展可以是函数方法的扩展,通过函数传参、函数内直接this指针访问到扩展所在的那个对象,完成单一功能。

或者是扩展一个模块,同一类模块中,留下基础的模块,其它都能以扩展的方式加入进来,比如在已有的基础元素类上扩展一个自定义图形。

层级则较为固定,通常不支持再扩展出新的层级。

插件:属于一种特殊的扩展。函数和模块的扩展通常是在应用实例化之前,插件则更多是依赖于一个已经实例化了的应用,可以动态添加和卸载。所以,插件一般会设计生命周期函数(初始化、更新、卸载等)。初始化时向插件传入应用实例,插件调用应用暴露的方法实现能力,进行事件监听等。

无论是扩展方法、模块还是插件,最好都提供统一的注册入口,让主实例能够知晓这些扩展的存在,便于管理。插件还可以考虑支持设置初始化顺序,对外提供插件查询的接口,这样能应对插件之间有互相依赖的情况。

// =====都通过 1个 includes 方法扩展到 App上========
Enter.Workspace.includes({
    removeAllElements(){ this.getElements()... }
    ...
});
// =====基于已有的视图模块,实现一个 Bar模块==========
class CollapseTree extends Enter.Tree{ }
// 用一个入口注册模块
Enter.register('CollapseTree',CollapseTree);

11. 具体情况具体分析