2024年8月

SOLID原则是由人称”鲍勃大叔“的Rober C. Martin所提出来的。他用五个面向对象设计原则的首字母组成了SOLID,并使其得到了广泛传播。这五个原则罗列如下:

  • 单一指责原则(Single Responsibility Principle):类的职责应该是单一的。所谓单一,是从变化的维度衡量的,既一个类应该只有一个变化的原因。
  • 开闭原则(Open-Closed Principle):设计模块应该对修改封闭,对拓展开放。
  • 里氏替换原则(Liskov Substitution Principle):子类应该完整地实现父类所要求的所有行为。在替换父类后不会导致程序的行为发生变化。
  • 接口隔离原则(Interface Segregation Principle):类之间的依赖应该建立在最小的接口上。不应该让使用方依赖于它们用不到的方法。
  • 依赖倒置原则(Dependency Inversion Principle):高层模块不应该依赖于低层模块,二者都应该依赖于接口。

SOLID原则涉及到面向对象的许多方面,例如内聚性、耦合性、良好的关注点分离等。尽管
这五个原则既不全面,也不正交,却依然有非常积极的指导意义
。它们并不仅仅局限于面向对象设计中,从函数、类、组件、再到系统架构,在软件设计的各个层次中它们都是优秀的指导方针。下面笔者将逐一介绍,并从自身的主业 前端/客户端开发领域中摘取例子来细化讲解。

SRP:单一职责原则

单一职责是首要介绍的,同时也是笔者认为最简单却最重要的一个原则。我们这样子来描述它:

任何一个软件模块都应该有且仅有一个被修改的理由。

无论我们在做何种层次的设计中,都是在不断地根据目标分解模块元素并为这些模块分配职责;模块间互相协作,组合起来成为一个更大的模块从而完成更大的职责,自下而上最终构成了完整的系统。以下从不同视角来举些正面/反面例子,帮助我们更好的理解该原则:

1. 低内聚的组件

在可视化编辑器的物料库中有一种类型的SVG组件如下所示,该SVG组件既能接受一系列配置来更新SVG渲染的样式、还可以在路径上播放物移动的动效:

如此带来的一个后续问题是,
processStyle
方法依赖于
getParsedPath
方法;而动画效果是后续添加的新功能,同样也直接依赖于
getParsedPath
。当最初维护该SVG组件的同学接到修改需求后,根据需要对
getParsedPath
方法内部做了调整,并在验证满足了新需求后边提交了更改。但是,同样依赖于
getParsedPath
方法的
processAnimation
却并不知情该变更,并在下一次发版后不再能于其正常协作而抛出了异常。

一个显而易见的地方是,动画处理的职责并不应该属于SVG组件。当我们将这些不同职责的代码放在一块时,就容易产生冲突。要解决上述问题,思路便是:将不该属于SVG组件的职责给剥离出来。

这里新产生了一个
AnimationController
类,专职来处理动效,并被SVG组件所关联。同时,这两者都依赖于有关路径的处理方法,并且在
可预见
的未来中,类似功能会有很大概率复用到;因此抽离出一组通用于SVG路径处理相关的工具函数
SVG Utils

经过重构之后的代码,尽管在代码量上略有增加并且调用关系相对复杂了一些,却带来了更多的好处:

  • 隔离职责,降低了模块变更带来的风险
  • 关注点分离地更加清晰
  • 代码复用性的提升

2. 包含越多职责的模块越容易产生冲突

通常来说,一个模块对应的只有一个源文件,可能是一个函数、一个类或是一个组件。而同一模块中所包含的职责越多,它所面对的维护者就越多。我们就以上面未重构前的SVG组件为例子:

当某一天SVG组件和动画效果都接收到了新需求,亦或是需要处理Bug。而这两项任务刚好是由两个开发者承接的;这很常见,因为动画需求往往不是针对某一个组件的而是针对库中一批同类别的组件,所以将其分配给不同的人开发是合理的。

接着这两位开发者就从主干上拉出了新分支到本地,在完成自己的任务后,再将其合并回主干上。因为他们都在SVG组件的那个源码文件上做了修改,不出意外地在合并代码的过程中就产生了冲突。这时就得其中的一位开发者来完成冲突处理的“脏活”,阅读冲突部分相关的代码并同另一位开发者交流确保没有歧义后再提交合并后的代码。

依据职能将代码进行分割后可以很大程度避免这种情况发生。尽管对于大部分人尤其是项目熟手来说,会觉得处理代码合并过程中产生的冲突只是小事一桩。但对于一个多人协作完成的大型项目来说,频繁触发的代码合并冲突,就意味着更多的额外工作量以及更高的出错概率。

3. 高内聚的组件带来更好的可维护性

我们以Unity下一个支持热更新的项目架构为例,简化为如下所示:

自下而上地简述下各组件的功能:

  • Unity引擎核心:自立项之初就确定的底层框架,在此之后
    几乎
    不可能变更。构建后为一份可执行文件及DLL。
  • 库文件与自己编写的游戏脚本:该部分主要由C#脚本实现游戏中的通用支持和性能敏感的逻辑模块,如引擎API桥接、资源加载/卸载、网络请求、游戏寻路算法等。在不同的构建方式下中间流程会有差异(Mono/IL2CPP),但最终产物都是DLL文件。
  • Lua脚本:游戏中会被频繁修改的业务逻辑大部分都放在了此处,例如UI、角色战斗逻辑、怪物的AI等。其是Unity能够支持热更新的原理所在。
  • Asset Bundle:游戏中代码以外的资产(模型、纹理、prefab和音视频等资源)都可以打成ab包。支持在游戏运行时动态地下载/卸载资源。

通过以上对各组件的简述,我们很容易看出各个组件之间有非常明显的边界;它们所拥有的职责,需要被修改的理由都非常明确。如果将SRP原则从系统组件层面上描述的话,可以说是
把那些为了相同目的而修改的文件都放到同一个组件中;那么在需要修改时我们只需将变更到尽可能少的组件,并能够独立地将它们发布、验证及部署

当游戏需要即时修复线上Bug、调整数值时,只需通过
热更新
替换lua文件即可。通过加载ab包,可以在遇到临时的节日运营活动/审核政策调整时可以立即替换游戏内的美术资源。在游戏底层机制经过许多改动后发布大版本更新时,则需要以
冷更新
的方式,在游戏客户端停机后替换新构建的DLL等文件。

OCP:开闭原则

设计良好的计算机软件应该易于拓展,同时抗拒修改。

先对这句话中的两个概念再作一次翻译,“拓展”指的是增加代码实体,“修改”指的是在已有的代码实体上进行修改。这个原则要求我们不应该在实现新需求时总需要去对既有的代码作出修改,而是只需要增添新代码即可;否则的话,随着后续新需求的不断增加,同一模块内代码的复杂性和出错的风险就会不断增加,这就是一个不好的设计。

同单一指责原则一样,开闭原则大多数时候都不是作为一种设计手段,而是
检验手段
;用于判断一个设计是否足够的好。我们来看一个富文本渲染的例子。在一个支持渲染多种元素的富文本应用中,如果缺乏合理抽象的设计,可能会写出如下“面条式”的代码:

class RichEditor extends React.Component {
    // ...

    renderElement(type, params) {
        if(type === 'img') {
            /* ... */
        } else if(type === 'url') {
            /* ... */
        } else if(type === 'dateTime') {
            /* ... */
        }
    }

    render() {
        // ...
    }
}

很显然,这是一个不内聚的模块设计,不符合单一指责原则。我们再以开闭原则的视角去审视它:

现在有了一个新需求,富文本还要能够渲染一块表格内容。大多数人最自然的选择就是在
renderElement()
后边再加一个新的判断条件:

else if(type === 'table') {/* ... */}

这是一个典型的违反开闭原则的例子:不支持拓展,需要对既有代码作修改。对于渲染类型的关注点都集中在了
renderElement()
里面,随着需求的不断增加,我们会在这个函数中不断地追加代码,致使
renderElement()
越来越复杂和臃肿。

让我们把这个设计重构一下吧。

首先将控制元素渲染的逻辑从
RichEditor
中剥离出来,实现一个管理器
ElementRegister
接管这部分逻辑,并提供注册接口用作拓展入口。这样一来就成了
RichEditor
依赖
ElementRegister

ElementRegister
依赖于具体的元素渲染实现(
RichEditor ---> ElementRegister ---> xxxElement
)。从依赖链条来看如此设计和及和原先的代码没本质区别,因为
RichEditor
始终还是依赖到底层的具体实现,
对修改不够封闭
。我们再将依赖方向
反转
一下,提供一个接口
IElement
,变为由
RichEditor
依赖于接口而不再是具体实现。底层的渲染元素负责实现
IElement
接口:

调整为这种依赖结构后,底层元素的具体实现对上层就不可见了,
RichEditor
/
ElementRegister
面向的只是接口
IElement
了;对于修改就封闭了。当有新元素需要增加时,则只需要再添加一个符合接口的实体即可了;对于拓展也是开放的了。

组合优于继承

再来看一个例子,选取自阿里的Galacean引擎专栏中对系统架构的介绍:

场景中的实体(Entity)是在运行时被创建出来的一个个对象,能够在场景中被渲染出来并通过添加组件(Component)的方式来提供各种能力。
基于组件进行架构的系统,组合优先于继承。比如希望一个实体既可以发光也可以出声,那么添加灯光组件和声音组件就能做到了。这种方式非常适合互动这种复杂度高的业务——特定功能只增加一个组件即可,便于扩展。

如果是采用继承的方式,那么在每次需要新添有特定功能的实体时,都有可能需要调整原先的继承关系。尤其是在整棵继承链上的类关联较复杂,层级结构较深的情况下,这种关系是非常脆弱的。在互动小游戏业务的迭代中这样需要频繁新增实体的场景下,就意味着继承关系可能会被频繁破坏。这样
对修改毫不封闭
,会造成极大的维护成本。反之,以组合式扁平的结构,
对拓展更为友好
,通过增删组件的方式可以拓展出许多中不同的新实体(在这种架构之下,所有的实体通常都只需要继承一个统一的基类即可)。

LSP:里氏替换原则

里氏替换原则由
Barbara Liskov
提出,其表述如下:

若每个类型 S 的对象 o1,都存在一个类型 T 的对象 o2,使得在所有针对 T 编写的程序 P 中,用 o1 替换 o2 后,程序 P 的行为功能不便,则 S 是 T 的派生类型。

将这段学术化的表达翻译成大白话就是子类应该完整地实现父类所要求的所有行为,这样一来在使用了父类的程序中,即使后续替换了其他子类后该程序也不会受到影响/感知不到变动。

根据上述表达可以知道,一个符合里氏替换原则的设计最为明显的好处在于,对于高层模块所依赖的类/接口,如果所有继承子类/接口都按预期实现了所要求的行为,那么这些依赖就都具有了可替换性。大大降低了在后期需要替换底层依赖时的迁移成本。下面就举一个笔者在项目实践中遇到的具体例子:

设计实现一个地图建模引擎,除了展示地图的底图外还能够回显/用户手动绘制各种图形、绘制路径、播放动画等。业务领域的需求是已经确定好了的,我们准备先使用高德的地图SDK(AMap)来作为底层的地图渲染引擎,完成功能后部署一套在公网上方便给客户演示。后续是在甲方的内网环境上部署的,因此还需要开发去驻场,地图SDK肯定不能是高德的,而是替换为甲方指定的了。

所以考虑对屏蔽掉具体的地图引擎,使其对上层的业务方不可见。提供一个抽象类
AbstractMapWidgetClass
,在该类中定义了底层的地图引擎应该具备那些行为能力。上层业务依赖于该抽象类:

接着使用高德地图的SDK来实现一个
AMap
类,使他继承自
AbstractMapWidgetClass
并正确实现父类要求的所有行为。后续在不同的甲方环境中部署时要使用到不同的SDK也是以同样的方式进行替换;只需新建一个子类继承自抽象父类即可。对于上层应用来说,因为它只依赖于抽象而不是具体的底层模块,所以我们在替换任意地图渲染引擎后都不会影响到它。

在软件架构层面,同样也应该注意到LSP原则的应用。系统架构中那些在未来预期中可能产生变动的部分如上层应用所依赖的底层模块、平台的基础设施等,都应该具备较高可替换性,使得后期迁移时不对上层造成影响(这里的“上、下层”,都是相对而言的)。

使用方与替换部分之间的衔接桥梁,就是
接口(Interface)
。这里可以延伸出
面向接口编程
这一重要的思维方式(这里所说的接口并不是指面向对象语言中的接口,而是广义上的接口,或许更贴近的叫法应该是“契约”);笔者将在最后再展开这部分的探讨。

ISP:接口隔离原则

ISP是一项指导接口该如何设计的原则。它建议接口应尽量的小并且内聚,依赖方使用不到的东西就不应出现在接口中。违反这项原则的场景往往不是在从零实现的新设计中,而是在功能演进时产生的:

起初我们的可视化设计器只有报告设计器
FreeReport
(一种类似于PPT的自由布局),有一个上下文
FreeReportContext
的依赖。这个上下文实现了相应的接口
IContext
。这些在一开始看起来都很良好,但问题显现是在后续我们新增了新的设计器之后。后续新增了仪表盘设计器
Dashboard
,它也有一个上下文的依赖
DashboardContext
。该上下文同样实现自
IContext
接口。

现在的问题在于,
IContext
中定义的大多数行为
FreeReportContext

DashboardContext
都应该满足。但原先的极个别方法例如上图中的页面信息
pageCount
在新添的
DashboardContext
没有相应的行为,而
DashboardContext
所需要新加入的
minimap
行为在
FreeReportContext
中又是不被需要到的。如果后期再继续拓展新的设计器类型,那么上述情况则会愈加地多,往
IContext
添加的任何新行为都会影响到先前所有的接口实现类。

对于不支持的行为,基于TS语法我们可以将其设为可选性
?
或者直接实现为空。这么做不符合语义,也没有必要。合理的解决方案应该是将不同依赖方所依赖的不共同的行为“分离”开来。可以有以下两种方式:

如此一来
FreeReportContext

DashboardContext
所依赖的接口都变得更干净了:接口中不再包含有自己不需要的行为了。至于上面两个方案哪个更优,这就要取决于系统未来的演进方式了。

DIP:依赖倒置原则

高层模块不应该依赖于低层模块,二者都应该依赖于抽象。

当我们修改抽象接口的时候,对应的具体实现一定也要做修改。但修改了具体实现后,相应的抽象接口则不一定需要做修改。此外,抽象的接口通常来说都是经过精心设计的,在未来的演进过程中会被做调整的概率更小。因此,我们可以说抽象接口这一层是稳定的。让高层模块和低层模块都依赖于抽象,能够带来更为稳定的设计。

依赖倒置原则这儿笔者不打算再举新的例子。回顾上面几项设计原则的例子中,特别是富文本和地图的例子中。我们能发现优化后的设计方案都是如下图形式的,通过让高层模块和低层模块都依赖于接口,使得本来指向低层模块的依赖箭头方向“倒置”了上去:

前面还提到了一个概念:面向接口编程。我们再回顾一遍里氏替换、接口隔离、依赖倒置这三项原则,它们都是面向接口编程的具体表现。所以笔者觉得最后有必要再提一下这一编程范式。

面向接口编程的方式把”A依赖于一个具体的B“变成了”A依赖于接口定义的标准“或者”A依赖于接口定义的能力“。这是一个非常重要的思维模式的不同。

”A依赖于一个具体的B“类似于我们在日常生活中遇到的非标准件。假设汽车上的一个小零件坏了,而且这个零件是一个非标准件,那么需要把车开到专门的汽车门店去修理。这个门店要是没有这种零件,就还需要花费时间订购。可如果是家里的灯泡坏了,那么只需要到附近的五金店,就可以买到新的,很快就能修好。之所以能有这种便利,是因为所有灯泡都必须遵循国家标准,从而能够灵活互换。更重要的是:标准化的接口让所有家庭的照明系统和各家照明设备制造厂商成功解耦了,仅和国家标准存在耦合。国家标准非常稳定,自然整个照明系统的维护成本就大幅降低了。

标准化是现代工业的基础。对于在上一段中提到的标准化,现行最新的标准是 GB/T 1406.1-2008《灯头的形式和尺寸》。例如,日常生活中最常用的灯头是E27螺口灯头,更细一点的是E14灯头。正是因为有了这些标准,各家灯具制造厂商和灯泡制造厂商才可以各自独立,产品互相兼容。这种简单性和互换性也是软件系统设计所追求的目标。尽管由于软件系统的复杂度要远远超出照明系统,导致实现完全的标准化定义非常困难,但是依赖于接口,而不是依赖于具体的实现,是一个普遍的原理。

接口作为一项“设计契约”,分离了做什么(接口)和怎么做(接口的具体实现)这两个关注点。接口实现方需要确保自己正确履行抽象接口中定义的所有职责,而接口依赖方在确保自己正确地调用接口的情况下则可以获得相对应的服务。由此带来更为稳定的设计。

代码层面的优化

1. 使用实例池

EFCore2.0 为DbContext引入新的注册方式:透明地注册了 DbContext实例池,使用这种方式可以避免始终创建新的实例,EF Core 将重置其状态并将其存储在内部池中;当下次请求新的实例时,将返回该共用实例,而不是设置新的实例

使用示例:

services.AddDbContext<HandshakesWebDBContext>(options => options.UseSqlServer(connectionConfiguration.WebDBConnection));

替换为

builder.Services.AddDbContextPool<HandshakesWebDBContext>(options => options.UseSqlServer(connectionConfiguration.WebDBConnection), poolSize: 80);
//注意设置最大连接数,一旦超过默认配置的连接池最大数量,会回退到按需创建实例的行为

基准测试(官方)
测试代码

方法 数量 平均值 错误 标准偏差 Gen 0 Gen 1 Gen 2 已分配
WithoutContextPooling 1 701.6 us 26.62 us 78.48 us 11.7188 - - 50.38 KB
WithContextPooling 1 350.1 us 6.80 us 14.64 us 0.9766 - - 4.63 KB

注意事项:虽然在大部分情况下这种做法对性能的提升可能并不是非常明显,但是这是一种好的实践方式,避免资源浪费的同时对性能带来一定的提升。

2. 使用拆分查询

了解什么是
笛尔卡乘积
?

通俗地来讲指的是从两个集合(Set)中的元素组成新的配对集合 以麦当劳套餐来比喻,门店将汉堡线和饮品线上的每个产品集合组成一个新的套餐会有多少种套餐

在数据库中的表现形式正是联表查(join)操作 两个表在数据量不是很大的情况下查询来讲可能对性能影响模棱两可 但是对于一些因业务需求日益增加列的大宽表以及数据存量过大的表来讲就会产生查询过慢以及数据冗余的问题
尤其适合一对多且子表数据量较大的场景。

看一段Linq代码:

var data = ctx.As
    .Include(x => x.Bs)
    .Include(x => x.Cs)
    .ThenInclude(x => x.D1s)
    .Include(x => x.Cs)
    .ThenIncude(x => x.C1s)
    .ThenInclude(x=>x.D2s)
    .ToList();

监控查看生成的Sql语句:

SELECT [A].[Id], [A].[Name], 
       [B].[Id], [B].[AId], [B].[Name],
       [C].[Id], [C].[AId], [C].[Name],
       [D1].[Id], [D1].[CId], [D1].[Name],
       [C1].[Id], [C1].[CId], [C1].[Name],
       [D2].[Id], [D2].[C1Id], [D2].[Name]
FROM [As] AS [A]
LEFT JOIN [Bs] AS [B] ON [A].[Id] = [B].[AId]
LEFT JOIN [Cs] AS [C] ON [A].[Id] = [C].[AId]
LEFT JOIN [D1s] AS [D1] ON [C].[Id] = [D1].[CId]
LEFT JOIN [C1s] AS [C1] ON [C].[Id] = [C1].[CId]
LEFT JOIN [D2s] AS [D2] ON [C1].[Id] = [D2].[C1Id]

毫无疑问,这一段糟糕的sql语句,假设每张表的数据量都很大的情况下,这对查询无疑是一种很大的负担,如果条件再复杂一点,对整个语句的分析也是很糟糕的。
关于阿里开发规范中定义超过3张表的join查询是被禁止的 (未查证)
,这个可能只是为了开发规范和管理,从技术角度出发,其实是没有这样的原则性问题的。

解决方案:使用SplitQuery,从字面意义就可以理解,即将这些join查询拆分成单个查询来执行

示例代码(推荐):

var data = ctx.As
    .Include(x => x.Bs)
    .Include(x => x.Cs)
    .ThenInclude(x => x.D1s)
    .Include(x => x.Cs)
    .ThenIncude(x => x.C1s)
    .ThenInclude(x=>x.D2s)
    .AsSplitQuery() //设置为拆分查询
    .ToList();

当然也可以在全局进行配置 (但是一般不推荐这样做,最好根据每个查询的实际情况,使用上面推荐的方式)

builder.Services.AddDbContext<CRSGEntityDbContext>(options => options.UseSqlServer(builder.Configuration["ConnectionStrings:FiinGroupDB"], o => o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)));

生成的sql

SELECT [a].[Id], [a].[OtherColumns]
FROM [As] AS [a]

SELECT [b].[Id], [b].[AId], [b].[OtherColumns]
FROM [Bs] AS [b]
INNER JOIN [As] AS [a] ON [b].[AId] = [a].[Id]

SELECT [c].[Id], [c].[AId], [c].[OtherColumns]
FROM [Cs] AS [c]
INNER JOIN [As] AS [a] ON [c].[AId] = [a].[Id]

SELECT [d1].[Id], [d1].[CId], [d1].[OtherColumns]
FROM [D1s] AS [d1]
INNER JOIN [Cs] AS [c] ON [d1].[CId] = [c].[Id]
WHERE [c].[AId] IN (SELECT [a].[Id] FROM [As] AS [a])

SELECT [c1].[Id], [c1].[CId], [c1].[OtherColumns]
FROM [C1s] AS [c1]
INNER JOIN [Cs] AS [c] ON [c1].[CId] = [c].[Id]
WHERE [c].[AId] IN (SELECT [a].[Id] FROM [As] AS [a])

SELECT [d2].[Id], [d2].[C1Id], [d2].[OtherColumns]
FROM [D2s] AS [d2]
INNER JOIN [C1s] AS [c1] ON [d2].[C1Id] = [c1].[Id]
WHERE [c1].[CId] IN (SELECT [c].[Id] FROM [Cs] AS [c] WHERE [c].[AId] IN (SELECT [a].[Id] FROM [As] AS [a]))

可以看到查询被拆分成了独立的语句,逻辑更加清晰,对于数据库来说执行效率也会更好。

注意事项:虽然拆分查询可以通过避免笛尔卡爆炸带来的性能问题,但是也需要根据实际的查询场景来决定是否使用,例如,需要对数据进行排序,分页,分组等操作的时候,为了保证查询结果的正确性,就需要考虑是否要使用拆分查询

关联话题:关于懒加载,其实懒加载的问题原因就等同于在循环中执行sql语句,示例代码:

//在没有显示加载的情况下,直接循环查询子对象
foreach (var blog in context.Blogs.ToList())
{
    foreach (var post in blog.Posts)
    {
        Console.WriteLine($"Blog {blog.Url}, Post: {post.Title}");
    }
}

观察sql日志:

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      SELECT [b].[BlogId], [b].[Rating], [b].[Url]
      FROM [Blogs] AS [b]
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (5ms) [Parameters=[@__p_0='1'], CommandType='Text', CommandTimeout='30']
      SELECT [p].[PostId], [p].[BlogId], [p].[Content], [p].[Title]
      FROM [Post] AS [p]
      WHERE [p].[BlogId] = @__p_0
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (1ms) [Parameters=[@__p_0='2'], CommandType='Text', CommandTimeout='30']
      SELECT [p].[PostId], [p].[BlogId], [p].[Content], [p].[Title]
      FROM [Post] AS [p]
      WHERE [p].[BlogId] = @__p_0
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (1ms) [Parameters=[@__p_0='3'], CommandType='Text', CommandTimeout='30']
      SELECT [p].[PostId], [p].[BlogId], [p].[Content], [p].[Title]
      FROM [Post] AS [p]
      WHERE [p].[BlogId] = @__p_0
... and so on

正确的做法即使用Include或者Load显示加载数据。

3. 使用批处理语句

批处理语句是EFCore7 版本中更新的重要功能,解决了以往版本需要借助第三方库来实现数据的批量更新,删除操作,而且在性能上带来了更大的提升

3.1 批量删除

之前版本的做法(不借助第三方库)

foreach (var blog in context.Blogs.Where(b => b.Rating < 3))
{
    context.Blogs.Remove(blog);
}
context.SaveChanges();

使用ExecuteDelete,无论是从语法上还是性能上,批处理操作都优于前者。

context.Blogs.Where(b => b.Rating < 3).ExecuteDelete();

如果是EFCore版本低于7.0,也可以使用直接执行sql语句 ExecuteSqlRaw 的方式来进行操作

context.Database.ExecuteSqlRaw("DELETE FROM [Blogs] WHERE [Rating] < 3");

3.2 批量更新

用法与Delete 基本相同

context.Blogs
    .Where(b => b.Rating < 3)
    .ExecuteUpdate(setters => setters.SetProperty(b => b.IsVisible, false));

注意事项:目前仅支持关系型数据库,而且需要由于是及时发送上下文请求,所以如果要支持事务,需要使用显示事务来与其他代码组合

4. 使用非跟踪查询

这个比较简单,在你不需要对查询结果进行任何更新操作的场景下,尽量使用非跟踪查询

var blogs = context.Blogs
    .AsNoTracking()
    .ToList();

或者

context.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
var blogs = context.Blogs.ToList();

测试代码

//代码执行前已对数据库进行预热处理
//执行5次
double elapsedTime4 = MeasureTime(() => context.Blogs.FirstOrDefault(x => x.Id == 1), 5);
double elapsedTime5 = MeasureTime(() => context.Blogs.AsNoTracking().FirstOrDefault(x => x.Id == 1), 5);

Console.WriteLine($"Tracked time took : {elapsedTime4} ms");
Console.WriteLine($"AsNoTracking() time took : {elapsedTime5} ms");

//Consoles:
//Tracked time took : 318.26 ms
//AsNoTracking() time took : 229.86 ms

5. 仅投影需要的字段

严格意义上来讲这是一个意识问题,大多数情况下,为了节省代码量,可以直接使用DataSet 定义的对象来直接进行查询,或者使用Include加载关联表数据,但是在遇到大量数据查询或大量的表连接查询的时候,精准的属性投影对性能就会起到很大的影响

示例代码:

var data = ctx.As
    .Where(x => x.Name.StartWith("xxx"))
    .ToList();

foreach (var item in data.Bs)
{
    Console.WriteLine($"Name :{item.Name},Id: {item.Id}");
}

上述代码中,我们仅需要查询主表及子表的id和name信息,但是却加载了所有的相关的主表和子表字段,这对性能是一种浪费

解决方案:通过Select投影需要查询的字段

var data = ctx.As
    .Where(x => x.Id=1)
    .Select(x => new {x.Id, x.Name})
    .ToList();

个人习惯性做法


//1,不依赖于数据库外键的设置
var query = from b in context.Blogs
            join c in context.Comments on b.blogId equals c.blogId
            join d in context.Posts on d.commentId equals c.commentId
            select new A{blogId = b.blogId,postId = d.postId,postValue = d.postValue}

这种做法对多表联查和大数据量的查询很有用 ,但需要注意的是这种做法并不适合需要更新数据的场景,因为 EF 的更改跟踪仅适用于实体实例。

6. 尽量使用异步方法

EFCore 基本上对所有同步操作方法都提供了对应的异步方法,尽量使用他们避免阻塞,减少对线程的需要和必须发生的线程上下文切换的次数,从而提升性能。

//ToListAsync
var data = await context.blogs.ToListAsync();
//FirstOrDefaultAsync
var item = await context.blogs.FirstOrDefaultAsync(it => it.Id == 1);
item.point=2;
//SaveChangesAsync
await context.SaveChangesAsync();
//AsAsyncEnumerable
var groupedHighlyRatedBlogs = await context.Blogs
    .AsQueryable()
    .Where(b => b.Rating > 3) // server-evaluated
    .AsAsyncEnumerable()
    .GroupBy(b => b.Rating) // client-evaluated
    .ToListAsync();

异步编程在efcore中在大多数情况被推荐使用,但是需要注意避免使用异步方法查询文本或二进制数据类型的内容,这样反而会引起性能问题(
sqlclient
的问题),issue报告:
EF Core - Memory and performance issues with async methods
Reading large data (binary, text) asynchronously is extremely slow

避免混合使用同步和异步方法,当你的程序请求量较大的时候,很可能导致连接池耗尽,从而引起的性能问题。

7. 使用Find查找单个目标数据

设计为在已知主键时高效查找单个实体。 Find 首先检查实体是否已被跟踪,如果是,则立即返回该实体。 只有当未在本地跟踪实体时,才执行数据库查询,而First/FirstOrDefault会立即查询数据库。

//代码执行前已对数据库进行预热处理
double elapsedTime4 = MeasureTime(() => context.blogs.Find(1);
double elapsedTime5 = MeasureTime(() => context.blogs.Find(1);

Console.WriteLine($"Find() first time took : {elapsedTime4} ms");
Console.WriteLine($"Find() second time took : {elapsedTime5} ms");

//Consoles:
//Find() first time took : 268.41 ms
//Find() second time took : 0.16 ms

注意,只能通过键查询的时候可以用。

8. 使用Any判断数据内容

在检查某些数据是否存在的时候,优先使用Any,这样在匹配到第一条数据后,查询就会停止,First因为需要返回数据,增加了数据传输和对象实例化的开销,Count则需要扫描表

double elapsedTime1 = MeasureTime(() => context.Blogs.Any(it => it.Id == 1));
double elapsedTime2 = MeasureTime(() => context.Blogs.Count(it => it.Id == 1), 1);
double elapsedTime3 = MeasureTime(() => context.Blogs.FirstOrDefault(it => it.Id == 1), 1);

Console.WriteLine($"Any() time took: {elapsedTime1} ms");
Console.WriteLine($"Count() time took: {elapsedTime2} ms");
Console.WriteLine($"FirstOrDefault() time took: {elapsedTime3} ms");

//Consoles:
//Any() time took: 237.42 ms
//Count() time took: 239.69 ms
//FirstOrDefault() time took: 258.28 ms

9.使用流式处理

首先了解什么是缓冲和流式处理

  • 缓冲:将需要的数据全部加载到内存中,用于后续的业务逻辑处理
  • 流式处理:按需获取需要的数据并应用到后续的逻辑处理中

形象的理解,缓冲用水桶把水挑起来,然后倒进缸里,流式处理就是用一根水管把水抽到缸里

原则上,流式处理查询的内存要求是固定的:无论查询返回 1 行还是 1000 行,内存要求都相同。另一方面,返回的行数越多,缓冲查询需要的内存越多。 对于产生大型结果集的查询,这可能是一个重要的性能因素。 反之,如果你的查询结果量很小,那么使用缓冲的效果可能返回会更好。

//一次性将数据加载出来
var blogsList = context.Posts.Where(p => p.Title.StartsWith("A")).ToList();
var blogsArray = context.Posts.Where(p => p.Title.StartsWith("A")).ToArray();

//使用流式处理,每次处理一行
foreach (var blog in context.Posts.Where(p => p.Title.StartsWith("A")))
{
    //do some things...
    SomeDotNetMethod(blog)
}

// 也可以使用AsEnumerable实现
var doubleFilteredBlogs = context.Posts
    .Where(p => p.Title.StartsWith("A")) // 执行数据库查询
    .AsEnumerable()
    .Where(p => SomeDotNetMethod(p)); //执行客户端操作

流式处理适合处理大量数据需要进行某些业务逻辑的加工或执行,但是数据库又无法支持响应的方法或函数,这个时候可以适用流式处理来进行操作。

10. 使用SQL查询

在某些特殊的情况下,例如一些复杂的sql查询,无法直接使用linq语法来实现的,EFCore也支持直接使用SQL语句进行查询或数据更新操作

10.1. 基本查询(实体)

场景:最终返回的结果与Dataset中定义的实体一致

//使用FromSql

//执行表查询
var blogs = context.Blogs
    .FromSql($"SELECT * FROM dbo.Blogs")
    .ToList();

//执行存储过程查询返回实体
var blogs = context.Blogs
    .FromSql($"EXECUTE dbo.GetMostPopularBlogs")
    .ToList();

10.2. 标量查询(非实体)

场景:最终返回的结果为自定义结构,而非数据库实体

//使用SqlQuery

//执行查询,返回单个字段
var ids = context.Database
    .SqlQuery<int>($"SELECT [BlogId] FROM [Blogs]")
    .ToList();

//执行查询,返回自定义数据结构
var comments = context.Database
    .SqlQuery<int>($"SELECT b.[BlogId],c.[CommnetContent] FROM [Blogs] b JOIN [Comments] c on b.BlogId = c.BlogId")
    .ToList();

public class CustomBlog{
    public int BlogId
    public string CommnetContent
}

10.3. 执行非查询SQL

场景:提交更新,删除等操作,不关注返回结果

//使用ExecuteSql

//执行更新
context.Database.ExecuteSql($"UPDATE [Blogs] SET [Url] = NULL WHERE Id =1");
//执行删除
context.Database.ExecuteSql($"DELETE FROM [Blogs] WHERE Id =1");

10.3. SQL参数

//使用FromSql

//此代码无效,因为数据库不允许将列名(或架构的任何其他部分)参数化
var propertyName = "User";
var propertyValue = "johndoe";

var blogs = context.Blogs
    .FromSql($"SELECT * FROM [Blogs] WHERE {propertyName} = {propertyValue}")
    .ToList();

//正确姿势:使用 FromSqlRaw
var columnName = "Url";
var columnValue = new SqlParameter("columnValue", "http://SomeURL");

var blogs = context.Blogs
    .FromSqlRaw($"SELECT * FROM [Blogs] WHERE {columnName} = @columnValue", columnValue)
    .ToList();

其他关联性优化

除了针对EFCore本身的一些优化技巧之外,还有一些技巧可以帮助我们提升数据查询的效率,我们可以利用vs的调试工具帮助我们监听内存使用,CPU占用率等指标,查找瓶颈,总结主要从以下几个方面进行优化

  1. 尽量避免循环内查询,分析实际的业务逻辑,尽可能的一次性从数据库加载所有需要的数据,再进行循环处理
  2. 分片处理条件数据,例如使用Chunk,使用流式处理大批量的数据集的运算
  3. 使用合理的数据结构,例如在不关注数据顺序的场景下使用Dictionary或HashSet代替List等
  4. 使用缓存减少热点数据的访问(按需设计)
  5. 使用数据表索引及物化视图(数据库)
  6. 采用分库分表,读写分离,使用ES进行检索(架构级优化)
  7. 利用多线程并发提升效率(不到万不得已,慎用)

总结

EFCore的优化主要是从几个方面来进行:
1.减少数据库的交互,通过连接复用,上下文缓存等
2.减少内存的使用,例如使用流式处理,分页查询等
3.降低查询复杂度,尽量在程序中处理复杂的逻辑

保持良好的编码习惯,使用正确的数据结构和处理逻辑,优化应该是渐进式的,先正确的满足需求,在遇到性能问题的时候借助代码或工具去分析瓶颈,再去进行针对性的优化,不要为了优化而牺牲需求和浪费工作量。

最后留给大家一段问题代码示例,感兴趣的童鞋可以尝试利用上述手段优化这段代码,看看效率提升有多少:


var configuration = new ConfigurationBuilder()
            .SetBasePath(AppDomain.CurrentDomain.BaseDirectory)
            .AddUserSecrets<Program>()
            .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
            .Build();

var serviceProvider = new ServiceCollection()
            .AddDbContext<YouContext>(options =>
                options.UseSqlServer(configuration["ConnectionStrings:YourContext"])
                        .EnableSensitiveDataLogging()
                        .UseLoggerFactory(LoggerFactory.Create(builder =>
                            {
                                builder.AddConsole().AddFilter((category, level) => category == DbLoggerCategory.Database.Command.Name && level == LogLevel.Information);
                            })))
            .BuildServiceProvider();

using (var scope = serviceProvider.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<YourContext>();
context.Database.SetCommandTimeout(999);
    var data = context.RELATIONSHIP.Where(x => x.workflow_state == 3).OrderBy(it => it.relationship_guid).Take(100000).ToList();
    var tempData = data.Select(it => new Temp { aId = it.entity_from_guid, bId = it.entity_to_guid, deg = 0 }).ToList();

    foreach (var item in tempData)
    {
        item.deg = GetInterConnectResult(item.aId, item.bId);
    }

    int GetInterConnectResult(Guid aId, Guid bId)
    {
        HashSet<Bo> boData = new();
        for (int i = 1; i <= 3; i++)
        {
            if (boData.Any(it => it.guid == bId)) break;
            var addIds = boData.Where(it => it.deg == i - 1).Select(it => it.guid).Distinct().ToList();
            var addRelationships = context.Table1.Where(it => addIds.Contains(it.aid) || addIds.Contains(it.bid));

            var addDegEntities = addRelationships.Select(it => new
            {
                efguid = it.aid,
                etguid = it.bid
            }).Union(addRelationships.Select(it => new
            {
                efguid = it.bid,
                etguid = it.aid
            }))
            .Select(it => new Bo { guid = it.etguid, deg = i })
            .ToHashSet() ?? new();
            boData.UnionWith(addDegEntities ?? new());
        }

        return boData?.OrderByDescending(it => it.deg)?.FirstOrDefault(it => it.guid.Equals(bId))?.deg ?? 0;
    }
}

作者:百宝门-周志飞

1.前言

Megacity Demo发布于2019年春,本博文撰写于2024年,ECS也早已Release并发布了1.2.3版本。

不过好在核心变化不大,多数接口也只是换了调用名称,

该Demo相较于之前的Book of the Dead(2018年发布),体量要小一些,主要演示DOTS相关内容。

近期刚好空闲,并且工程文件与2019.2版本躺硬盘已久,经典适合反复研究,故把坑填上。

该Demo已上传百度网盘:

链接:
https://pan.baidu.com/s/1X1gh6hQSRuB0KenlRZsOiw
提取码:iios

打开请使用Unity2019.1.0b7,其中Unity Package部分包会从Unity服务器下载,版本过老,

不保证是否能正确拉取,可以自行修复。

2.Hybrid ECS 部分

先讲一讲用到Hybrid ECS的几个功能。

2.1 HLOD

打开主场景MegaCity.unity后,在任意Section SubScene内,可以看见一些模型都套用有HLOD组件,

HOLD指的是
场景内的细碎物件在到达最后一级LOD时,将这些物件的最后一级LOD合并进一个Mesh进行显示
,例如远处的三四个房屋,电线杆

等等。合批后将替换为合并Mesh的单个模型,而模型合并操作可以
离线进行,提前生成好

HOLD的缺点是内存中需要多放置HLOD模型,并且存在负优化的情况,具体看项目而定。

在MegaCity Demo中可通过脚本CombineMeshFromLOD.cs进行HLOD模型的离线创建。

而HLOD脚本则是Hybrid ECS内封装了部分功能,通过ECS计算HLOD的显示替换等一些逻辑处理,使用时需要确保LOD Group组件的LOD数量

与HLOD中的LodParentTransforms一致即可,例如下图中有2个Low LOD的GameObject,实际上是2个级别的HLOD:

(理论上是单个HLOD Mesh替换,但实际Unity支持多级别HLOD)

2.2 SubScene

SubScene是Unity通过DOTS实现的子场景嵌套功能,其核心博主认为是Unity开放的流式场景加载接口:

m_Streams[i].Operation = newAsyncLoadSceneOperation(entitiesBinaryPath, sceneData.FileSize, sceneData.SharedComponentCount, resourcesPath, entityManager);
m_Streams[i].SceneEntity
= entity;

同时SubScene也附带了将场景内容转换为适合流式加载的二进制格式

3.ECS的一些常见概念

在开始看MegaCity之前,我觉得应该先写一些ECS的前置概念。

3.1 筛选机制

常规编写一个Manager类会通过注册(Register)/反注册(Unregister)的机制管理该类的对象,

而ECS中这样的逻辑变为了筛选机制,以MegaCity的BoxTriggerSystem为例,这是一个类似处理OnTriggerEnter事件触发的碰撞管理系统,

碰撞盒的注册通过HybridECS的Mono转换组件进行:

ECS的System中,筛选代码如下:

m_BBGroup =GetComponentGroup(newEntityArchetypeQuery
{
All
= new ComponentType[] { typeof(BoundingBox) },
None
= new ComponentType[] { typeof(TriggerCondition) },
Any
= Array.Empty<ComponentType>(),
});

其中含有BoundingBox的ComponentData将会被筛选到对应System中进行处理。

而传统Manager的Unregister操作在ECS中则是将这个ComponentData移除,这样下一帧筛选时就不会筛选到了。

3.2 Jobs中CommandBuffer处理

还是以MegaCity Demo的BoxTriggerSystem为例,struct Job用于处理多线程的各项任务,并可以通过Burst对底层代码进行加速,

而在Job中不能进行如ComponentData移除这样的删改操作,我们可以通过CommandBuffer来加入到操作队列,在Job结束之后进行处理,

这和渲染管线处理上的CommandBuffer有点像:

public structTriggerJob : IJobChunk
{
publicEntityCommandBuffer.Concurrent m_EntityCommandBuffer;
  //...
public void Execute(ArchetypeChunk chunk, int chunkIndex, intfirstEntityIndex)
{
//...
//add trigger component m_EntityCommandBuffer.AddComponent(chunkIndex, newBoundingBox, newTriggerCondition());
}
}

3.3 标记逻辑处理

那么像BoxTriggerSystem这样的碰撞管理器,如何对已经产生碰撞的对象进行标记?

其实也是通过筛选处理的,在产生碰撞后为对应Entity实体增加一个ComponentData,TriggerCondition:

m_EntityCommandBuffer.AddComponent(chunkIndex, newBoundingBox, new TriggerCondition());

筛选时跳过含有TriggerCondition的实体即可:

m_BBGroup =GetComponentGroup(newEntityArchetypeQuery
{
All
= new ComponentType[] { typeof(BoundingBox) },
None
= new ComponentType[] { typeof(TriggerCondition) },
Any
= Array.Empty<ComponentType>(),
});

而在另一个音乐处理的System中,又会拿到标记了TriggerCondition和MusicTrigger的实体:

m_TriggerData =GetComponentGroup(newEntityArchetypeQuery
{
All
= new ComponentType[] { typeof(TriggerCondition), typeof(MusicTrigger) },
None
= Array.Empty<ComponentType>(),
Any
= Array.Empty<ComponentType>()
});

所以ECS的思路就是通过标记来代替传统OnEnable/OnDisable消息事件的触发。

4.MegaCity Demo本体

4.1 场景结构

先来看下静态置于MegaCity场景中的内容结构。

  1. Audio存放了音频配置,MegaCity运用了Unity开放出来的ECS音频模块DSPGraph,不过当时(指MegaCity Demo发布时)实现比较简陋,大概是满足了基本使用需求的情况。
  2. Pathing存放了飞船的路径信息,也是该Demo想展示的一个点。
  3. 玩家飞船相关的逻辑不写了,这部分没有用到DOTS

4.2 LightPoolSystem

LightPoolSystem主要是用ECS的形式,遍历当前飞船和相机视锥范围内的灯光,进行逻辑筛选并进行对象池复用。

因为借助了HDRP渲染管线,场景内的灯光将和体积雾效果产生交互,达到较好的显示呈现。

其中LightRef脚本用于将场景中的灯光转换进ECS:

来到LightPoolSystem的OnUpdate中,对其中逻辑进行快速讲解:

protected overrideJobHandle OnUpdate(JobHandle handle)
{
if (Camera.main == null || !AdditiveScene.isLoaded)returnhandle;#region Setup new lights #region Find closest lights #region Assign instances #region Update light intensity returnhandle;
}

1).第一步Setup new lights,拿到没有标记LightPoolCreatedTag组件数据的SharedLight,筛选结构如下:

m_NewSharedLights =GetComponentGroup
(
ComponentType.ReadOnly
<SharedLight>(),
ComponentType.Exclude
<LightPoolCreatedTag>()
);

SharedLight就是场景中HybridECS的转换对象,对应的MonoBehavior转换脚本是LightRef

假设场景内当前加载了50盏灯光,那么这一步也会创建50个实体,但对应的对象池则是用到了哪种灯光模板在惰性创建。

这一步最后再标记上LightPoolCreatedTag,防止下一次Update时进入这部分逻辑。

2).第二步Find closest lights,对已经映射上的场景灯光进行视锥和距离筛选,存入另一份NativeArray - ClosestLights。

3).第三步Assign instances分配实例,对已经筛选出来的实体分配具体灯光,并存入另一份NativeArray - AssignedLights,方便后续操作。

4).第四步Update light intensity更新灯光强度,直接操作AssignedLights更新灯光亮度,对于Active标记为False的灯光,

将不断变暗直到亮度数值为0并进行回收。

4.3 StreamingLogic

流式加载场景的封装逻辑,因为Unity SubScene并没有完全封装对应的加载卸载逻辑处理,

只提供了接口,我们还需要额外编写一层逻辑。

玩家对象上挂有配置脚本StreamingLogicConfigComponent处理流式加载的参数:

然后System中进行少量逻辑处理,最后用挂载ComponentData的方式通知Unity ECS的流失加载系统进行加载:

structBuildCommandBufferJob : IJob
{
publicEntityCommandBuffer CommandBuffer;public NativeArray<Entity>AddRequestArray;public NativeArray<Entity>RemoveRequestArray;public voidExecute()
{
foreach (var entity inAddRequestArray)
{
CommandBuffer.AddComponent(entity,
default(RequestSceneLoaded));
}
foreach (var entity inRemoveRequestArray)
{
CommandBuffer.RemoveComponent
<RequestSceneLoaded>(entity);
}
}
}

4.4 Megacity Audio System

或许这个系统才是重点,但发现主要仍是Unity的封装。

首先在Package Manager中可以看见该系统的相关代码,同时也可以发现AudioMixer中空空如也,这也MegaCity Demo的不同之处,

其内部所有的音频都是基于这套系统开发的。

在项目宏定义处加上ENABLE_DSPGRAPH_INTERCEPTOR开启调试器:

开启后可以在Window/DSP Graph处打开调试器窗口,可看见所有的音频Graph结构最终如何汇总输出:

Megacity demo中飞机之间快速擦过(FlyBySystem)以及交通中的各类音频都是调用了这个System

其中ECSoundEmitterComponent可挂载,类似于AudioSource:

游戏内的音频会先挂载到PlaybackSystem,好比先把Audio放置于Graph内,再将音频暂时关闭,需要时打开:

var playbackSystem = World.Active.GetOrCreateManager<SamplePlaybackSystem>();
playbackSystem.AddClip(clip);

而真正去用,则是其他地方另行处理,可以看见读取缓存的AudioClip通过GetInstanceID:

var sample =EntityManager.CreateEntity();
AddClip(clip);
EntityManager.AddComponentData(sample,
newAdditiveState());
EntityManager.AddComponentData(sample,
new SamplePlayback { Volume = 1, Loop = 1, Pitch = 1});
EntityManager.AddComponentData(sample,
new SharedAudioClip { ClipInstanceID =clip.GetInstanceID() });
m_SampleEntities.Add(sample);

最后看音效实现,好像没有对应接口,也是通过类似挂载AudioClip的方式,定时播放和移除挂载。

其思路和Wwise/FMod也不相似,没有事件逻辑,只是性能系统设计。

4.5 Traffic 交通逻辑处理

这是MegaCity Demo中最让我眼前一亮的模块。

4.5.1 道路处理

MegaCity Demo中玩家路径用的是Cinemachine Path,NPC飞船用的路径是自己写的Path.cs:

若需要编辑Path,需要勾选Show All Handles,Show Coloured Roads则是查看路网的开关。

Is On Ramp用于标记主干道(匝道),Percetage Chance For On Ramp用于标记从分支进入主干道的概率。

勾选Show Coloured Roads:

4.5.2 NPC飞船寻路处理

NPC飞船通过Path拿到道路信息,并且通过CatmullRom插值进行路径计算,非常巧妙的一点是它利用了

CatmulRom的导数得到曲线变化率,并以此直接作为系数实现飞船移动的匀速曲线采样:

public void Execute(ref VehiclePathing p, ref VehicleTargetPosition pos, [ReadOnly] refVehiclePhysicsState physicsState)
{
var rs =RoadSections[p.RoadIndex];

float3 c0
=CatmullRom.GetPosition(rs.p0, rs.p1, rs.p2, rs.p3, p.curvePos);
float3 c1
=CatmullRom.GetTangent(rs.p0, rs.p1, rs.p2, rs.p3, p.curvePos);
float3 c2
=CatmullRom.GetConcavity(rs.p0, rs.p1, rs.p2, rs.p3, p.curvePos);float curveSpeed =length(c1);

pos.IdealPosition
=c0;
pos.IdealSpeed
=p.speed;if (lengthsq(physicsState.Position - c0) <kMaxTetherSquared)
{
p.curvePos
+= Constants.VehicleSpeedFudge / rs.arcLength * p.speed / curveSpeed *DeltaTimeSeconds;
}
}

其中c1是一阶导数,c2是二阶导数。最后一行计算时为什么还要除以arcLength不太清楚。

5.杂项

5.1 ComponentDataFromEntity<T>通过实体快速映射组件

以Demo中的代码为例:

foreach (var newFlyby in New)//New = Entities
{var positional = PositionalFromEntity[newFlyby];

可以通过这个类直接得到组件,目前在新版本ECS中该类改名为了:

ComponentLookup<T>

5.2 DelayLineDopplerHack

这个脚本放在了Script文件夹外,并没有在项目里实装,它用了比较HACK的方法直接处理音频,并且

尝试实现哈斯HAAS效应:

var haasDelay = (int)((s.m_Attenuation[0] - s.m_Attenuation[1]) * m_Haas * (c * 2 - 1));var delaySamples = Mathf.Clamp (delaySamplesBase + haasDelay, 0, maxLength);

哈斯(Haas)通过实验表明:两个同声源的声波若到达听音者的时间差Δt在5~35ms以内,人无法区分
两个声
源,给人以方位听感的只是前导声(超前的声源),滞后声好似并不存在;若延迟时间Δt在35~50ms时,人耳开始感知滞后声源的存在,但听感做辨别的方位仍是前导声源;若时间差Δt>50ms时,人耳便能分辨出前导声与滞后声源的方位,即通常能听到清晰的回声。哈斯对双声源的不同延时给人耳听感反映的这一描述,称为哈斯效应。这种效应有助于建立
立体声
的听音环境

5.3 ChunkEntityEnumerable

通过工具类ChunkEntityEnumerable,简化了在Job中遍历Chunk时的翻页处理:

public boolMoveNext()
{
if (++elementIndex >=currChunkLength)
{
if (++chunkIndex >=chunks.Length)
{
return false;
}

elementIndex= 0;
currChunk =chunks[chunkIndex].GetNativeArray(entityType);
currChunkLength =currChunk.Length;
}
return true;
}


Unity2022新版MegacityDemo下载:
https://unity.com/de/demos/megacity-competitive-action-sample

Unity多人联机版本Megacity:
https://unity.com/cn/demos/megacity-competitive-action-sample

Unity2019旧版本Megacity下载:
https://discussions.unity.com/t/megacity-feedback-discussion/736246/81?page=5

Book of the Dead 死者之书Demo工程回顾与学习:
https://www.cnblogs.com/hont/p/15815167.html

使用场景

在生产环境中,遇到一个需求,需要在一个深色风格的大屏页面中,嵌入 Google Maps。为了减少违和感,希望地图四边能够淡出过渡。

这里的“淡出过渡”,关键是淡出,而非降低透明度。

基于 Google Maps 的深色示例中,附加上述需求,效果如下:

image

简单的说,就是中间放地图,四周放标题和其它展板内容。

image

CSS mask-image + SVG

简化一下,把地图换成图片,实现一个示例。

示例中,注释掉“mask”标记的内容,恢复“svg test”标记的内容,可以查看 svg 。

准备工作,定义一个“容器”和“目标”层:

<div id="container">
  <img id="target" src="https://cdn.pixabay.com/photo/2024/07/28/09/04/mountain-8927018_1280.jpg">
  
  <!-- svg test -->
  <!-- <div id="target" style="width:1920px;height:1080px;"></div> -->
</div>

基础样式:

body {
  margin: 0;
  background-color: black;
}

#container {
  position: absolute;
  width: 100%;
  height: 100%;
  background-repeat: repeat;
  display: flex;
  align-items: center;
  justify-content: center;
}

#target {
  max-width: 80%;
  max-height: 80%;
  
  /* mask */
  -webkit-mask-mode: alpha;
  mask-mode: alpha;
  mask-repeat: no-repeat;
  mask-size: 100% 100%;
  
  /* svg test */
  /* background-repeat: no-repeat;
  background-size: 100% 100%; */
}

给“容器”添加一个波点背景,为了验证淡出过渡区域可以透视背景,这里直接用 svg 实现:

(function() {
  const container = document.querySelector('#container');
  const containerBg = `<svg xmlns="http://www.w3.org/2000/svg" width="30" height="30"><circle fill="rgba(255,255,255,0.1)" cx="15" cy="15" r="10" /></svg>`;
  container.style.backgroundImage = `url('data:image/svg+xml;utf8,${encodeURIComponent(containerBg)}')`;
  // 略
})();

接着给“目标”准备一个处理方法,如果目标是一个图片,为了获得图片大小,将在图片的 onload 中执行:

(function() {
  // 略
  const target = document.querySelector('#target');

  function setTargetBg() {
    // 略
  }

  target.onload = setTargetBg
  
  setTargetBg()
})();

为了实现淡出过渡效果,需要准备一个 svg:

分为 4+1 块,上下左右 4 个梯形 path,中间 1 个矩形 rect。
4 个梯形分别设置了 4 个方向的 linearGradient 渐变。

image

这里用代码绘制上面的 svg:

svg 的宽高是基于“目标”的宽高,淡入过渡区域大小 padding 基于“目标”短边的 20%。
特别地,patch 和 rect 中的加减“1”,目的是为了消除 path 之间的缝隙。

  function setTargetBg() {
    const svgWidth = target.offsetWidth,
      svgHeight = target.offsetHeight,
      padding = Math.floor(Math.min(target.offsetWidth, target.offsetHeight) * 0.2),
      fill = 'white',
      patch = 0.2;

    const targetMask = `
  <svg xmlns="http://www.w3.org/2000/svg"
    xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1"
    width="${svgWidth}"
    height="${svgHeight}" viewBox="0 0 ${svgWidth} ${svgHeight}">
    <defs>
      <linearGradient id="mask-bottom-to-top" x1="0" x2="0" y1="0" y2="1">
        <stop offset="0%" stop-color="transparent" />
        <stop offset="100%" stop-color="${fill}" />
      </linearGradient>
      <linearGradient id="mask-top-to-bottom" x1="0" x2="0" y1="0" y2="1">
        <stop offset="0%" stop-color="${fill}" />
        <stop offset="100%" stop-color="transparent" />
      </linearGradient>
      <linearGradient id="mask-rigth-to-left" x1="0" x2="1" y1="0" y2="0">
        <stop offset="0%" stop-color="transparent" />
        <stop offset="100%" stop-color="${fill}" />
      </linearGradient>
      <linearGradient id="mask-left-to-right" x1="0" x2="1" y1="0" y2="0">
        <stop offset="0%" stop-color="${fill}" />
        <stop offset="100%" stop-color="transparent" />
      </linearGradient>
    </defs>
    <path fill="url(#mask-bottom-to-top)" d="M0,0 L${svgWidth},0 L${svgWidth - padding + patch},${padding + patch} L${padding - patch},${padding + patch} Z"></path>
    <path fill="url(#mask-top-to-bottom)" d="M0,${svgHeight} L${padding - patch},${svgHeight - padding - patch} L${svgWidth - padding + patch},${svgHeight - padding - patch} L${svgWidth},${svgHeight} Z"></path>
    <path fill="url(#mask-rigth-to-left)" d="M0,0 L${padding + patch},${padding} L${padding + patch},${svgHeight - padding} L0,${svgHeight} Z"></path>
    <path fill="url(#mask-left-to-right)" d="M${svgWidth},0 L${svgWidth - padding - patch},${padding} L${svgWidth - padding - patch},${svgHeight - padding} L${svgWidth},${svgHeight} Z"></path>
    <rect x="${padding - 1}" y="${padding - 1}" width="${svgWidth - padding * 2 + 1 * 2}" height="${svgHeight - padding * 2 + 1 * 2}" fill="${fill}"></rect>
  </svg>
`;

	// mask
    target.style.maskImage = `url('data:image/svg+xml;utf8,${encodeURIComponent(targetMask.replace(/\n/g, ''))}')`;
    
    // svg test
    // target.style.backgroundImage = `url('data:image/svg+xml;utf8,${encodeURIComponent(targetMask.replace(/\n/g, ''))}')`;
  }

最终效果:

image

在线Demo

前言:

学习ComfyUI是一场持久战,而ComfyUI layer style 是一组专为图片设计制作且集成了Photoshop功能的强大节点。该节点几乎将PhotoShop的全部功能迁移到ComfyUI,诸如提供仿照Adobe Photoshop的图层样式、提供调整颜色功能(亮度、饱和度、对比度等)、提供Mask辅助工具、提供图层合成工具和工作流相关的辅助节点、提供图像效果滤镜等。旨在集中工作平台,使我们可以在ComfyUI中实现PhotoShop的一些基础功能。

目录

一、安装方式

二、LayerStyle:ColorOverlay节点

三、LayerStyle:DropShadow节点

四、LayerStyle:GradientOverlay节点

五、LayerStyle:InnerGlow节点

六、LayerStyle:InnerShadow节点

七、LayerStyle:OuterGlow节点

八、LayerStyle:Stroke节点

一、安装方式

方法一:通过ComfyUI Manager安装(推荐)

打开Manager界面

1

2

方法二:使用git clone命令安装

在ComfyUI/custom_nodes目录下输入cmd按回车进入电脑终端

3

在终端输入下面这行代码开始下载

git clone https://github.com/chflame163/ComfyUI_LayerStyle.git

4

二、LayerStyle:ColorOverlay节点

这一节点的设计目的是通过覆盖颜色层来改变图像的整体色调或添加特定的颜色效果,增强图像的视觉表现力。

5

输入:

background_image → 输入的背景图片

layer_image → 用于合成的层图像

layer_mask → 层图像的遮罩

注意:这三项必须是相同的尺寸,另mask不是必须的输入项

参数:

invert_mask → 是否反转遮罩

blend_mode → 描边的混合模式(一共有19种混合模式)

opacity → 不透明度

color → 覆盖的颜色

输出:

image → 处理后的图片

示例:简单节点示例

6

示例:19种描边混合模式

7

8

注意事项

· 颜色选择:选择适合处理目标的覆盖颜色,以实现预期的视觉效果。

· 不透明度配置:根据具体需求设置不透明度,控制颜色覆盖的强度。一般来说,不透明度值在0到1之间,0表示完全透明,1表示完全不透明。

· 输入图像质量:输入图像的质量会影响颜色覆盖的效果,确保图像清晰且色彩信息完整。

· 处理性能:颜色覆盖处理可能需要一定的计算资源,确保系统性能足够支持处理需求。

· 结果检查:应用颜色覆盖后,检查图像的整体视觉效果,确保颜色覆盖效果符合预期且没有影响到图像的重要细节。

通过使用LayerStyle: ColorOverlay节点,可以在图像处理工作流程中实现高效的颜色覆盖效果,提升图像的视觉表现力和艺术效果。

三、LayerStyle:DropShadow节点

这一节点的设计目的是通过添加阴影,使图像中的元素看起来更加立体和突出,增加视觉深度和层次感。

9

输入:

background_image → 输入的背景图片

layer_image → 用于合成的层图像

layer_mask → 层图像的遮罩

参数:

invert_mask → 是否反转遮罩

blend_mode → 阴影的混合模式

opacity → 阴影的不透明度

distance_x → 阴影的水平方向偏移量

distance_y → 阴影的垂直方向偏移量

grow → 阴影扩张幅度

blur → 阴影模糊程度

shadow_color → 阴影颜色

输出:

image → 处理后的图片

示例:

10

注意事项

· 阴影颜色选择:选择适合处理目标的阴影颜色,以实现预期的视觉效果。

· 位置和模糊配置:根据具体需求设置阴影的偏移量和模糊半径,确保阴影效果自然且不遮盖图像的主要内容。

· 不透明度配置:根据具体需求设置阴影的不透明度,控制阴影效果的强度。一般来说,不透明度值在0到1之间,0表示完全透明,1表示完全不透明。

· 输入图像质量:输入图像的质量会影响阴影效果的视觉表现,确保图像清晰且重要元素位置明确。

· 处理性能:阴影效果处理可能需要一定的计算资源,确保系统性能足够支持处理需求。

· 结果检查:应用阴影效果后,检查图像的整体视觉效果,确保阴影效果符合预期且没有影响到图像的重要细节。

通过使用LayerStyle: DropShadow节点,可以在图像处理工作流程中实现高效的阴影效果,增强图像的立体感和视觉层次,使图像更加生动和具有吸引力。

四、LayerStyle:GradientOverlay节点

这一节点的设计目的是通过添加渐变色彩层来改变图像的整体色调或增强图像的视觉效果。

11

输入:

background_image → 输入的背景图片

layer_image → 用于合成的层图像

layer_mask → 层图像的遮罩

参数:

invert_mask → 是否反转遮罩

blend_mode → 描边的混合模式

opacity → 描边的不透明度

start_color → 渐变开始端的颜色

start_alpha → 渐变开始端的透明度

end_color → 渐变结束端的颜色

end_alpha → 渐变结束端的透明度

angle → 渐变旋转角度

输出:

image → 处理后的图片

示例:

12

注意事项

· 颜色选择:选择适合处理目标的渐变颜色,以实现预期的视觉效果。可以选择两个或多个颜色来创建渐变效果。

· 渐变方向:根据具体需求设置渐变的方向,常见方向有水平、垂直和对角线等。

· 不透明度配置:根据具体需求设置渐变的不透明度,控制渐变效果的强度。一般来说,不透明度值在0到1之间,0表示完全透明,1表示完全不透明。

· 输入图像质量:输入图像的质量会影响渐变覆盖的效果,确保图像清晰且色彩信息完整。

· 处理性能:渐变覆盖处理可能需要一定的计算资源,确保系统性能足够支持处理需求。

· 结果检查:应用渐变覆盖后,检查图像的整体视觉效果,确保渐变效果符合预期且没有影响到图像的重要细节。

通过使用LayerStyle: GradientOverlay节点,可以在图像处理工作流程中实现高效的渐变覆盖效果,提升图像的视觉表现力和艺术效果。

五、LayerStyle:InnerGlow节点

这一节点设计目的是通过在图像内部添加柔和的光晕效果,增强图像的立体感和视觉吸引力。

13

输入:

background_image → 输入的背景图片

layer_image → 用于合成的层图像

layer_mask → 层图像的遮罩

参数:

invert_mask → 是否反转遮罩

blend_mode → 发光的混合模式

opacity → 发光的不透明度

brightness → 发光亮度

glow_range → 发光范围

blur → 发光模糊程度

light_color → 发光中心颜色

glow_color → 辉光外围颜色

输出:

image → 处理后的图片

示例:

14

注意事项

· 发光颜色选择:选择适合处理目标的发光颜色,以实现预期的视觉效果。

· 发光半径和强度配置:根据具体需求设置发光的半径和强度,确保发光效果自然且不遮盖图像的主要内容。

· 不透明度配置:根据具体需求设置发光的不透明度,控制发光效果的强度。一般来说,不透明度值在0到1之间,0表示完全透明,1表示完全不透明。

· 输入图像质量:输入图像的质量会影响发光效果的视觉表现,确保图像清晰且重要元素位置明确。

· 处理性能:发光效果处理可能需要一定的计算资源,确保系统性能足够支持处理需求。

· 结果检查:应用发光效果后,检查图像的整体视觉效果,确保发光效果符合预期且没有影响到图像的重要细节。

通过使用LayerStyle: InnerGlow节点,可以在图像处理工作流程中实现高效的内发光效果,增强图像的立体感和视觉层次,使图像更加生动和具有吸引力。

六、LayerStyle:InnerShadow节点

这一节点的设计目的是通过在图像内部添加阴影,使图像中的元素看起来更加立体和具有深度。

15

输入:

background_image → 输入的背景图片

layer_image → 用于合成的层图像

layer_mask → 层图像的遮罩

参数:

invert_mask → 是否反转遮罩

blend_mode → 阴影的混合模式

opacity → 阴影的不透明度

distance_x → 阴影的水平方向偏移量

distance_y → 阴影的垂直方向偏移量

grow → 阴影扩张幅度

blur → 阴影模糊程度

shadow_color → 阴影颜色

输出:

image → 处理后的图片

示例:

16

注意事项

· 阴影颜色选择:选择适合处理目标的阴影颜色,以实现预期的视觉效果。

· 位置和模糊配置:根据具体需求设置阴影的偏移量和模糊半径,确保阴影效果自然且不遮盖图像的主要内容。

· 不透明度配置:根据具体需求设置阴影的不透明度,控制阴影效果的强度。一般来说,不透明度值在0到1之间,0表示完全透明,1表示完全不透明。

· 输入图像质量:输入图像的质量会影响阴影效果的视觉表现,确保图像清晰且重要元素位置明确。

· 处理性能:阴影效果处理可能需要一定的计算资源,确保系统性能足够支持处理需求。

· 结果检查:应用阴影效果后,检查图像的整体视觉效果,确保阴影效果符合预期且没有影响到图像的重要细节。

通过使用LayerStyle: InnerShadow节点,可以在图像处理工作流程中实现高效的内部阴影效果,增强图像的立体感和视觉层次,使图像更加生动和具有吸引力。

七、LayerStyle:OuterGlow节点

这一节点的设计目的是通过在图像外部添加光晕效果,使图像中的元素更加突出和具有光彩。

17

输入:

background_image → 输入的背景图片

layer_image → 用于合成的层图像

layer_mask → 层图像的遮罩

参数:

invert_mask → 是否反转遮罩

blend_mode → 发光的混合模式

opacity → 发光的不透明度

brightness → 发光亮度

glow_range → 发光范围

blur → 发光模糊程度

light_color → 发光中心颜色

glow_color → 辉光外围颜色

输出:

image → 处理后的图片

示例:

18

注意事项

· 发光颜色选择:选择适合处理目标的发光颜色,以实现预期的视觉效果。

· 发光半径和强度配置:根据具体需求设置发光的半径和强度,确保发光效果自然且不遮盖图像的主要内容。

· 不透明度配置:根据具体需求设置发光的不透明度,控制发光效果的强度。一般来说,不透明度值在0到1之间,0表示完全透明,1表示完全不透明。

· 输入图像质量:输入图像的质量会影响发光效果的视觉表现,确保图像清晰且重要元素位置明确。

· 处理性能:发光效果处理可能需要一定的计算资源,确保系统性能足够支持处理需求。

· 结果检查:应用发光效果后,检查图像的整体视觉效果,确保发光效果符合预期且没有影响到图像的重要细节。

通过使用LayerStyle: OuterGlow节点,可以在图像处理工作流程中实现高效的外部发光效果,增强图像的立体感和视觉层次,使图像更加生动和具有吸引力。

八、LayerStyle:Stroke节点

这一节点的设计目的是通过在图像元素周围添加轮廓线,使其更加突出和具有视觉层次感。

19

输入:

background_image → 输入的背景图片

layer_image → 用于合成的层图像

layer_mask → 层图像的遮罩

参数:

invert_mask → 是否反转遮罩

blend_mode → 发光的混合模式

opacity → 发光的不透明度

stroke_grow → 描边扩张/收缩幅度 (正值是扩张,负值是收缩)

stroke_width → 描边宽度

blur → 描边模糊程度

stroke_color → 描边颜色

输出:

image → 处理后的图片

示例:

20

注意事项

· 描边颜色选择:选择适合处理目标的描边颜色,以实现预期的视觉效果。

· 描边宽度配置:根据具体需求设置描边的宽度,确保描边效果明显但不遮盖图像的主要内容。

· 描边位置选择:根据具体需求选择描边的位置,常见位置包括内部、外部和居中。

· 输入图像质量:输入图像的质量会影响描边效果的视觉表现,确保图像清晰且重要元素位置明确。

· 处理性能:描边效果处理可能需要一定的计算资源,确保系统性能足够支持处理需求。

· 结果检查:应用描边效果后,检查图像的整体视觉效果,确保描边效果符合预期且没有影响到图像的重要细节。

通过使用LayerStyle: Stroke节点,可以在图像处理工作流程中实现高效的描边效果,增强图像的立体感和视觉层次,使图像更加生动和具有吸引力。

**孜孜以求,方能超越自我。坚持不懈,乃是成功关键。**