2024年8月

之前谈DDD时提及过当下很多标榜面向对象的语言,却是以面向过程来实现的问题。这里就从编码设计的角度来顺一下面向对象设计的一些思维。其实就像我正在梳理的设计模式一样,都是些老生常谈的东西,可是往往在实践的时候,这些老生常谈的东西会被“反刍”,总会有种常看常新的感觉。

面向对象思想

其实想要进行DDD实践,不可避免地就要进行OOA和OOD,这里主要是对OOD的一些设计准则和思想进行梳理。

抽象

面向对象的核心技术就是抽象,相比于面向过程基于数据结构进行步骤式命令开发的思维,面向对象则是以人的思维模式去进行思考,其中,对事物共性、本质的提取就是抽象。

打个比方,人作为现实生活中的一个实体,我们可以很直观的看到,人都会有性别、年龄、身高、体重等等的一些公共属性,除此之外,人还会使用语言沟通,会吃饭,会开车等一系列的行为,于是,我们进行总结,人是一种具有性别、年龄、体重……且会说话、睡觉…的物类,而这个总结,几乎适用于所有的人,于是,人类的概念被概括出来。通过这个过程就会发现,我们的思考过程是,先有了一个模糊的物类,然后在该物类中提取公共的部分进行整和,最后整个模糊的物类就具象化了,整个过程就是归纳、总结。这就是抽象。

对应到编程中,OOP需要对我们程序、业务中的一些主体进行特征的抽取、然后汇总,最后清晰的定义出来,这就是面向对象的第一步,即将实际场景存在或需求中的事物进行泛化,提取公共部分,进行类型的定义。抽象的结果是类型,也就是类。

对象

我们将一个定义抽象出来之后,可以根据这个定义,任意的产生一个具体的实例,这就是编程中的Class与具体的new Object,对象是根据抽象出的类型的实例化,我们定义了人类的特征和行为(即编写了一个Class),便可以根据这个Class,产出一个具体的个体来(new 出一个对象),就像我们每个人生活在地球这个环境中交流。程序也是一样,在面向对象的程序世界中,对象才是主角,程序是一个运行态,显然不是由抽象的类来工作,而是由抽象的类所具象化的一个个具体对象来通信、交流。

面向对象需要在意的几个意识:

  • 一切皆是对象
    :在程序中,任何事务都是对象,可以把对象看作一个奇特的变量,它可以存储,可以通信,可以从自身来进行各自操作,你总是可以从要解决的问题身上抽象出概念性的组件,然后在程序中将其表示为一个对象。
  • 程序是对象的集合,它通过发送消息来告知彼此需要做什么
    :程序就像是个自然环境,一个人,一头猪,一颗树,一个斧头,都是这个环境中的具体对象,对象之间相互的通信,操作来完成一件事,这便是程序中的一个流程,要请求调用一个对象的方法,你需要向该对象发送消息。
  • 每个对象都有自己的存储空间,可容纳其他对象
    :人会有手机,一个人是一个对象,一个手机也是一个对象,而手机可以是人对象中的一部分,或者说,通过封装现有对象,可制作出新型对象。所以,尽管对象的概念非常简单,但在程序中却可达到任意高的复杂程度。
  • 每个对象都拥有其类型
    :按照通用的说法,任何一个对象,都是某个“类(Class)”的实例,每个对象都必须有其依赖的抽象。
  • 同一类所有对象都能接收相同的消息
    :这实际是别有含义的一种说法,大家不久便能理解。由于类型为“圆”(Circle)的一个对象也属于类型为“形状”(Shape)的一个对象,所以一个圆完全能接收发送给"形状”的消息。这意味着可让程序代码统一指挥“形状”,令其自动控制所有符合“形状”描述的对象,其中自然包括“圆”。这一特性称为对象的“可替换性”,是OOP最重要的概念之一。

过程思维和对象思维

简单讲过程思维是数据结构加操作;对象思维则是一个整体,既包含数据结构又包含操作,也就是面向对象中的属性和行为。

面向对象设计原则

在进行面向对象设计和编码的道路上,众多知名前辈结合自己的实践和认知高度抽象概况出了具有指导思想意义的设计原则。这里的每个原则细细品来都是意味深长,但是需要注意的是,就像数据库范式一样,它是个指导思想,并不是需要一板一眼遵守的“准则”。

SRP-单一职责原则(Single Responsibility Principle)

单一职责的官方定义:

一个类应该只有一个引起它变化的原因

这里变化的原因就是所说的“职责”,如果一个类有多个引起它变化的原因,那么也就意味着这个类有多个职责,再进一步说,就是把多个职责耦合在一起了。这会造成职责的相互影响,可能一个职责的变化,会影响到其他职责的实现,甚至引起其他职责随着变化,这种设计是很脆弱的。
这个原则看起来是最简单和最好理解的,但是实际上是很难完全做到的,难点在于如何区分“职责”。这是个没有标准量化的东西,哪些算职责、到底这个职责有多大的粒度、这个职责如何细化等等,例如:

public classFileUtil {public voidreadFile(String filePath) {//读取文件的代码
}public voidwriteFile(String filePath, String content) {//写入文件的代码
}public voidencryptFile(String filePath) {//加密文件的代码
}public voiddecryptFile(String filePath) {//解密文件的代码
}
}

我们的开发习惯经常会根据一个对象或者概念+操作去定义一个Util,这个Util会作为公共处理代码来帮我们处理系统中关于文件相关的操作。但是严格来讲,这是违背了单一职责原则的,因为如果将来需要修改文件的读取逻辑或加密算法,可能会影响到其他功能,这就违反了单一职责原则。如果想要严格遵守单一职责,应该改为:

//负责文件读取的类
public classFileReader {public voidreadFile(String filePath) {//读取文件的代码
}
}
//负责文件写入的类 public classFileWriter {public voidwriteFile(String filePath, String content) {//写入文件的代码 }
}
//负责文件加密的类 public classFileEncryptor {public voidencryptFile(String filePath) {//加密文件的代码 }public voiddecryptFile(String filePath) {//解密文件的代码 }
}

现在,每个类都只有一个职责:

  • FileReader
    类只负责读取文件。
  • FileWriter
    类只负责写入文件。
  • FileEncryptor
    类负责文件的加密和解密。

这样,每个类的变更原因都只有一个,符合单一职责原则。如果需要修改文件读取逻辑,只需要修改
FileReader
类;如果需要修改加密算法,只需要修改
FileEncryptor
类,而不会影响到其他类。但是实际项目中如果真严苛到每个操作都细化为一个类,多半会被人骂SB。

因此,在实际开发中,这个原则最容易被违反,因为这个度的把控是很难的。我们能做的就是基于项目实际情况的操作粒度来把控这个“职责”,如果项目中对于文件的操作,改动和牵扯范围很广,那严格遵守单一职责会带来很好的扩展性和维护性,但是如果项目十分简单,基于公共Util且万年不变,那完全没有必要进行单一职责改造,单体一个项目一个Util足够了。

OCP-开闭原则(Open-Closed Principle)

类应该对扩展开放,对修改关闭。

开闭原则要求的是,类的行为是可以扩展的,而且是在不修改已有代码的情况下进行扩展,也不必改动已有的源代码或者二进制代码。
这看起来好像是矛盾的,但这是指实际的编码过程中,毕竟这是一个指导思想,站在指导思想的角度上来看,也未必矛盾;实现开闭原则的关键就在于合理地抽象、分离出变化与不变化的部分,为变化的部分预留下可扩展的方式,比如,钩子方法或是动态组合对象等。
这个原则看起来也很简单。但事实上,一个系统要全部做到遵守开闭原则,几乎是不可能的,也没这个必要。适度的抽象可以提高系统的灵活性,使其可扩展、可维护,但是过度地抽象,会大大增加系统的复杂程度。应该在需要改变的地方应用开闭原则就可以了,而不用到处使用,从而陷入过度设计。

LSP-里氏替换原则(Liskov Substitution Principle)

子类对象应该能够替换掉它们的父类对象,而不影响程序的行为。

简单来讲就是子类可以替换掉父类在程序中的位置而不影响程序的使用,这是一种基于面向对象的多态的使用。它可以避免在多态的使用过程中出现某些隐蔽的错误。

public abstract classAccount {privateString accountNumber;private doublebalance;public Account(String accountNumber, doublebalance) {this.accountNumber =accountNumber;this.balance =balance;
}
publicString getAccountNumber() {returnaccountNumber;
}
public doublegetBalance() {returnbalance;
}
public void deposit(doubleamount) {
balance
+=amount;
System.out.println(
"Deposited: " + amount + ", New Balance: " +balance);
}
public abstract void withdraw(doubleamount);
}
//账户的派生类 public class CheckingAccount extendsAccount {private doubleoverdraftLimit;public CheckingAccount(String accountNumber, double balance, doubleoverdraftLimit) {super(accountNumber, balance);this.overdraftLimit =overdraftLimit;
}
@Override
public void withdraw(doubleamount) {if (amount <= balance +overdraftLimit) {
balance
-=amount;
System.out.println(
"Withdrew: " + amount + ", New Balance: " +balance);
}
else{
System.out.println(
"Insufficient funds for withdrawal: " +amount);
}
}
public doublegetOverdraftLimit() {returnoverdraftLimit;
}
}
//里氏替换使用场景 public classBank {private List<Account> accounts = new ArrayList<>();public voidaddAccount(Account account) {
accounts.add(account);
}
public voidprocessTransactions() {for(Account account : accounts) {
account.withdraw(
100); //假设每个账户都尝试取出100元 account.deposit(50); //假设每个账户都存入50元 }
}
}
public classMain {public static voidmain(String[] args) {
Bank bank
= newBank();
bank.addAccount(
new Account("123456", 1000));
bank.addAccount(
new CheckingAccount("789012", 500, 300));

bank.processTransactions();
}
}

这个符合里氏替换原则的样例的关键点是,无论是普通的
Account
对象还是
CheckingAccount
对象,都可以被
Account
类型的变量处理,而不需要任何特殊逻辑来区分它们。这就是里氏替换原则的体现:
CheckingAccount
对象可以无缝替换
Account
对象,而不会破坏
Bank
类的行为。

事实上,当一个类继承了另外一个类,那么子类就拥有了父类中可以继承下来的属性和操作。理论上来说,此时使用子类型去替换掉父类型,应该不会引起原来使用父类型的程序出现错误。
但是,在某些情况下是会出现问题的。比如,如果子类型覆盖了父类型的某些方法,或者是子类型修改了父类型某些属性的值,那么原来使用父类型的程序就可能会出现错误,因为在运行期间,从表面上看,它调用的是父类型的方法,需要的是父类型方法实现的功能,但是实际运行调用的却是子类型覆盖实现的方法,而该方法和父类型的方法并不一样,于是导致错误的产生。
从另外一个角度来说,里氏替换原则是实现开闭的主要原则之一。开闭原则要求对扩展开放,扩展的一个实现手段就是使用继承:而里氏替换原则是保证子类型能够正确替换父类型,只有能正确替换,才能实现扩展,否则扩展了也会出现错误

DIP-依赖倒置原则(Dependence Inversion Principle)

高层模块不应依赖于低层模块,两者都应该依赖于抽象;抽象不应依赖于细节,细节应依赖于抽象

所谓依赖倒置原则,指的是,要依赖于抽象,不要依赖于具体类。要做到依赖倒置典型的应该做到:

  • 高层模块不应该依赖于底层模块,二者都应该依赖于抽象。
  • 抽象不应该依赖于具体实现,具体实现应该依赖于抽象

很多人觉得,层次化调用的时候,应该是高层调用“底层所拥有的接口”,这是一种典型的误解。事实上,一般高层模块包含对业务功能的处理和业务策略选择,应该被重用,是高层模块去影响底层的具体实现。
因此,这个底层的接口应该是由高层提出的,然后由底层实现的。也就是说底层的接口的所有权在高层模块,因此是一种所有权的倒置。
比较经典的案例应该是COLA中提到数据防腐层设计,相关可以看我的
COLA框架
那一篇 。

ISP-接口隔离原则(Interface Segregation Principle)

不应该强迫客户依赖于它们不使用的方法。一个类不应该实现它不需要的接口。

这个原则用来处理那些比较“庞大”的接口,这种接口通常会有较多的操作声明,涉及到很多的职责。客户在使用这样的接口的时候,通常会有很多他不需要的方法,这些方法对于客户来讲,就是一种接口污染,相当于强迫用户在一大堆“垃圾方法”中去寻找他需要的方法。其实有一点“接口的单一职责”的意思。
因此,这样的接口应该被分离,应该按照不同的客户需要来分离成为针对客户的接口。这样的接口中,只包含客户需要的操作声明,这样既方便了客户的使用,也可以避免因误用接口而导致的错误。
分离接口的方式,除了直接进行代码分离之外,还可以使用委托来分离接口,在能够支持多重继承的语言中,还可以采用多重继承的方式进行分离。

通过一个正反案例体会一下,假设我们有一个银行系统,其中包括两种类型的账户:储蓄账户(SavingsAccount)和支票账户(CheckingAccount)。储蓄账户提供存款和获取利息的功能,而支票账户提供存款、取款和透支的功能。

反例:

interfaceBankAccount {void deposit(doubleamount);void withdraw(doubleamount);doublegetInterestRate();
}
class SavingsAccount implementsBankAccount {private doublebalance;public SavingsAccount(doubleinitialDeposit) {this.balance =initialDeposit;
}
@Override
public void deposit(doubleamount) {
balance
+=amount;
}
@Override
public void withdraw(doubleamount) {//储蓄账户不允许透支 if (amount <=balance) {
balance
-=amount;
}
else{throw new IllegalArgumentException("Insufficient funds");
}
}
@Override
public doublegetInterestRate() {return 0.03; //假设利息率为3% }
}
class CheckingAccount implementsBankAccount {private doublebalance;private doubleoverdraftLimit;public CheckingAccount(double initialDeposit, doubleoverdraftLimit) {this.balance =initialDeposit;this.overdraftLimit =overdraftLimit;
}
@Override
public void deposit(doubleamount) {
balance
+=amount;
}
@Override
public void withdraw(doubleamount) {if (amount <= balance +overdraftLimit) {
balance
-=amount;
}
else{throw new IllegalArgumentException("Insufficient funds for overdraft");
}
}
@Override
public doublegetInterestRate() {//支票账户通常没有利息 return 0.0;
}
}

这里
BankAccount
接口强制要求所有账户实现
getInterestRate()
方法,这违反了ISP,因为不是所有类型的账户都有利息。如果想要符合ISP,应该讲用户公共操作分为两个接口,进一步保证接口功能的单一性。

public interfaceAccount {void deposit(doubleamount);void withdraw(doubleamount);
}
public interfaceInterestBearing {doublegetInterestRate();
}
public class SavingsAccount implementsAccount, InterestBearing {private doublebalance;public SavingsAccount(doubleinitialDeposit) {this.balance =initialDeposit;
}

@Override
public void deposit(doubleamount) {
balance
+=amount;
}

@Override
public void withdraw(doubleamount) {if (amount <=balance) {
balance
-=amount;
}
else{throw new IllegalArgumentException("Insufficient funds");
}
}

@Override
public doublegetInterestRate() {return 0.03; //假设利息率为3% }
}
public class CheckingAccount implementsAccount {private doublebalance;private doubleoverdraftLimit;public CheckingAccount(double initialDeposit, doubleoverdraftLimit) {this.balance =initialDeposit;this.overdraftLimit =overdraftLimit;
}

@Override
public void deposit(doubleamount) {
balance
+=amount;
}

@Override
public void withdraw(doubleamount) {if (amount <= balance +overdraftLimit) {
balance
-=amount;
}
else{throw new IllegalArgumentException("Insufficient funds for overdraft");
}
}
}

Account
接口包含所有账户共有的操作,而
InterestBearing
接口仅包含与利息相关的操作。
SavingsAccount
类实现了
Account

InterestBearing
接口,因为它有利息收益。而
CheckingAccount
类只实现了
Account
接口,因为它没有利息收益。这样,我们就避免了强制要求
CheckingAccount
实现它不需要的
getInterestRate()
方法,从而遵循了接口隔离原则。

LKP-最少知识原则(Least Knowledge Principle)

又叫
迪米特法则(Law of Demeter, LoD)
,所谓最少知识,指的是,只和你的朋友谈话。
这个原则用来指导我们在设计系统的时候,应该尽量减少对象之间的交互,对象只和自己的朋友谈话,也就是只和自己的朋友交互,从而松散类之间的耦合。通过松散类之间的耦合来降低类之间的相互依赖,这样在修改系统的某一个部分的时候,就不会影响其他的部分,从而使得系统具有更好的可维护性。
那么究竟哪些对象才能被当作朋友呢?最少知识原则提供了一些指导。

  • 当前对象本身。
  • 通过方法的参数传递进来的对象。
  • 当前对象所创建的对象。
  • 当前对象的实例变量所引用的对象。
  • 方法内所创建或实例化的对象。

总之,最少知识原则要求我们的方法调用必须保持在一定的界限范围之内,尽量减少对象的依赖关系。

设计原则与设计模式

通过前面的内容,我们大概能有个粗略答案了,即设计原则是抽象,设计模式有点像“对象”。其实设计原则与设计模式也有点这么个意思。

设计原则大多从思想层面给我们指出了面向对象分析设计的正确方向,是我们进行面向对象分析设计时应该尽力遵守的准则。是一种“抽象”。

而设计模式已经是针对某个场景下某些问题的某个解决方案。也就是说这些设计原则是思想上的指导,而设计模式是实现上的手段,因此设计模式也应该遵守这些原则,换句话说,设计模式就是这些设计原则的一些具体体现。是“对象”。

关于设计原则与设计模式的认识和选择,主要有以下几点:

    • 设计原则本身是从思想层面上进行指导,本身是高度概括和原则性的。只是一个设计上的大体方向,其具体实现并非只有设计模式这一种。理论上来说,可以在相同的原则指导下,做出很多不同的实现来。
    • 每一种设计模式并不是单一地体现某一个设计原则。事实上,很多设计模式都是融合了很多个设计原则的思想,并不好特别强调设计模式对某个或者是某些设计原则的体现。而且每个设计模式在应用的时候也会有很多的考量,不同使用场景下,突出体现的设计原则也可能是不一样的。
    • 这些设计原则只是一个建议指导。事实上,在实际开发中,很少做到完全遵守,总是在有意无意地违反一些或者是部分设计原则。设计工作本来就是一个不断权衡的工作,有句话说得很好:“
      设计是一种危险的平衡艺术
      ”。设计原则只是一个指导,有些时候,还要综合考虑业务功能、实现的难度、系统性能、时间与空间等很多方面的问题。

在8月1日发布救援行动-
赞助商计划
后,我们并没有抱什么奢望,更没有妄想很快能找到赞助商,只是为救园多一点可能的希望,万一找到一家赞助商,就会多一份救园力量。

没想到第2天就有幸遇到一家有意向的企业,中午加微信开始沟通赞助商计划的细节,晚上快7点的时候就收到了赞助款,万一很快成真。

这家非同寻常的企业就是河南图奕网络科技有限公司,赞助的理由和很多出手相救的园友一样自然而不简单:

从我刚学编程的时候博客园就给我很大很大帮助

而且更加可贵的是河南图奕网络自己当前并不宽裕。

曾经的帮助,现在的鼎力相助,带来园子的第一个宝贵赞助,非常感谢河南图奕网络科技有限公司的赞助!

相信这样非同寻常的公司做产品也会很用心,而且他们的产品目标用户群也是开发者,欢迎大家关注一下他们旗下的两款产品。

第一款是
网云穿
,是一款内网穿透产品,已经运营了七八年。

第二款是
IShell
,是刚上线的一款原生SSH工具。

IShell现在有个小福利,加入IShell用户交流QQ群时备注「博客园」,可以免费领取三个月VIP。

注:
IShell官网
首页滚动至底部,上翻一屏,就会看到上图中的加群界面。

再次感谢河南图奕网络科技有限公司对园子的支持!

@


前言

请各大网友尊重本人原创知识分享,谨记本人博客: 南国以南i


提示:以下是本篇文章正文内容,下面案例可供参考

需求

当我们需要
求两个或两个以上的字符串相似度百分比
时,可以使用
HanLP汉语言
处理包来帮助我们,求两个文本的相似度百分比、海明距离、

简介

HanLP(Han Language Processing)
在汉语义处理方面具备强大的功能,由中国科学院计算技术研究所自然语言处理与社会人文计算研究中心开发。它提供了包括分词、词性标注、命名实体识别、依存句法分析、情感分析、文本分类等多种自然语言处理任务的功能。

主要功能:

  • 分词:
    HanLP提供了多种分词模型,包括基于规则的模型、基于神经网络的模型等,能够准确地进行中文分词。
  • 词性标注:
    在分词的基础上,HanLP还能对词语进行词性标注,帮助理解词语在句子中的作用。
  • 命名实体识别:
    HanLP能够识别文本中的命名实体,如人名、地名、组织机构名等,这对于信息抽取等任务非常重要。
  • 依存句法分析:
    通过对句子中各个词语之间的依存关系建模,HanLP能够分析句子结构,提取句子的语义信息。
  • 情感分析:
    HanLP支持对文本进行情感分析,判断文本所表达的情感倾向,如正面、负面或中性。
  • 文本分类:
    HanLP还提供了文本分类的功能,可以将文本按照预设的分类体系进行分类。

友情链接:
HanLP项目主页

HanLP下载地址

详细介绍

实操开始

注意:
本文以Java开发语言为案例

1. 添加pom.xml依赖

	    <!--junit依赖-->
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>3.8.1</version>
            <scope>test</scope>
        </dependency>
        <!--hanlp语言处理依赖-->
        <dependency>
            <groupId>com.hankcs</groupId>
            <artifactId>hanlp</artifactId>
            <version>portable-1.7.6</version>
        </dependency>
        <!-- https://mvnrepository.com/artifact/org.apache.commons/commons-lang3 -->
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
            <version>3.4</version>
        </dependency>
        <!-- https://mvnrepository.com/artifact/org.jsoup/jsoup -->
        <dependency>
            <groupId>org.jsoup</groupId>
            <artifactId>jsoup</artifactId>
            <version>1.10.3</version>
        </dependency>

2. 文本相似度工具类

import com.hankcs.hanlp.seg.common.Term;
import com.hankcs.hanlp.tokenizer.StandardTokenizer;
import org.apache.commons.lang3.StringUtils;
import org.jsoup.Jsoup;
import org.jsoup.safety.Whitelist;

import java.math.BigInteger;
import java.util.*;

public class MySimHash {
    private String tokens; //字符串
    private BigInteger strSimHash;//字符产的hash值
    private int hashbits = 64; // 分词后的hash数;


    public MySimHash(String tokens) {
        this.tokens = tokens;
        this.strSimHash = this.simHash();
    }

    private MySimHash(String tokens, int hashbits) {
        this.tokens = tokens;
        this.hashbits = hashbits;
        this.strSimHash = this.simHash();
    }


    /**
     * 清除html标签
     *
     * @param content
     * @return
     */
    private String cleanResume(String content) {
        // 若输入为HTML,下面会过滤掉所有的HTML的tag
        content = Jsoup.clean(content, Whitelist.none());
        content = StringUtils.lowerCase(content);
        String[] strings = {" ", "\n", "\r", "\t", "\\r", "\\n", "\\t", "&nbsp;"};
        for (String s : strings) {
            content = content.replaceAll(s, "");
        }
        return content;
    }


    /**
     * 这个是对整个字符串进行hash计算
     *
     * @return
     */
    private BigInteger simHash() {
        tokens = cleanResume(tokens); // cleanResume 删除一些特殊字符
        int[] v = new int[this.hashbits];
        List<Term> termList = StandardTokenizer.segment(this.tokens); // 对字符串进行分词
        //对分词的一些特殊处理 : 比如: 根据词性添加权重 , 过滤掉标点符号 , 过滤超频词汇等;
        Map<String, Integer> weightOfNature = new HashMap<String, Integer>(); // 词性的权重
        weightOfNature.put("n", 2); //给名词的权重是2;
        Map<String, String> stopNatures = new HashMap<String, String>();//停用的词性 如一些标点符号之类的;
        stopNatures.put("w", ""); //
        int overCount = 5; //设定超频词汇的界限 ;
        Map<String, Integer> wordCount = new HashMap<String, Integer>();
        for (Term term : termList) {
            String word = term.word; //分词字符串
            String nature = term.nature.toString(); // 分词属性;
            //  过滤超频词
            if (wordCount.containsKey(word)) {
                int count = wordCount.get(word);
                if (count > overCount) {
                    continue;
                }
                wordCount.put(word, count + 1);
            } else {
                wordCount.put(word, 1);
            }
            // 过滤停用词性
            if (stopNatures.containsKey(nature)) {
                continue;
            }
            // 2、将每一个分词hash为一组固定长度的数列.比如 64bit 的一个整数.
            BigInteger t = this.hash(word);
            for (int i = 0; i < this.hashbits; i++) {
                BigInteger bitmask = new BigInteger("1").shiftLeft(i);
                // 3、建立一个长度为64的整数数组(假设要生成64位的数字指纹,也可以是其它数字),
                // 对每一个分词hash后的数列进行判断,如果是1000...1,那么数组的第一位和末尾一位加1,
                // 中间的62位减一,也就是说,逢1加1,逢0减1.一直到把所有的分词hash数列全部判断完毕.
                int weight = 1;  //添加权重
                if (weightOfNature.containsKey(nature)) {
                    weight = weightOfNature.get(nature);
                }
                if (t.and(bitmask).signum() != 0) {
                    // 这里是计算整个文档的所有特征的向量和
                    v[i] += weight;
                } else {
                    v[i] -= weight;
                }
            }
        }
        BigInteger fingerprint = new BigInteger("0");
        for (int i = 0; i < this.hashbits; i++) {
            if (v[i] >= 0) {
                fingerprint = fingerprint.add(new BigInteger("1").shiftLeft(i));
            }
        }
        return fingerprint;
    }


    /**
     * 对单个的分词进行hash计算;
     *
     * @param source
     * @return
     */
    private BigInteger hash(String source) {
        if (source == null || source.length() == 0) {
            return new BigInteger("0");
        } else {
            /**
             * 当sourece 的长度过短,会导致hash算法失效,因此需要对过短的词补偿
             */
            while (source.length() < 3) {
                source = source + source.charAt(0);
            }
            char[] sourceArray = source.toCharArray();
            BigInteger x = BigInteger.valueOf(((long) sourceArray[0]) << 7);
            BigInteger m = new BigInteger("1000003");
            BigInteger mask = new BigInteger("2").pow(this.hashbits).subtract(new BigInteger("1"));
            for (char item : sourceArray) {
                BigInteger temp = BigInteger.valueOf((long) item);
                x = x.multiply(m).xor(temp).and(mask);
            }
            x = x.xor(new BigInteger(String.valueOf(source.length())));
            if (x.equals(new BigInteger("-1"))) {
                x = new BigInteger("-2");
            }
            return x;
        }
    }

    /**
     * 计算海明距离,海明距离越小说明越相似;
     *
     * @param other
     * @return
     */
    private int hammingDistance(MySimHash other) {
        BigInteger m = new BigInteger("1").shiftLeft(this.hashbits).subtract(
                new BigInteger("1"));
        BigInteger x = this.strSimHash.xor(other.strSimHash).and(m);
        int tot = 0;
        while (x.signum() != 0) {
            tot += 1;
            x = x.and(x.subtract(new BigInteger("1")));
        }
        return tot;
    }


    /**
     * .
     * 求百分比
     *
     * @param s2
     * @return
     */
    public double getSemblance(MySimHash s2) {
        double i = (double) this.hammingDistance(s2);
        return 1 - i / this.hashbits;
    }

}

详细说明:

/*--------------------------------------相似度算法说明--------------------------------------
    借鉴hashmap算法找出可以hash的key值,因为我们使用的simhash是局部敏感哈希,这个算法的特点是只要相似的字符串只有个别的位数是有差别变化。
    那这样我们可以推断两个相似的文本,至少有16位的simhash是一样的。具体选择16位、8位、4位,大家根据自己的数据测试选择,虽然比较的位数越小越精准,但是空间会变大。
    分为4个16位段的存储空间是单独simhash存储空间的4倍。之前算出5000w数据是 382 Mb,扩大4倍1.5G左右,还可以接受:)  通过这样计算,我们的simhash查找过程全部降
    到了1毫秒以下。就加了一个hash效果这么厉害?我们可以算一下,原来是5000w次顺序比较,现在是少了2的16次方比较,前面16位变成了hash查找。后面的顺序比较的个数是多少?
    2^16 = 65536, 5000w/65536 = 763 次。。。。实际最后链表比较的数据也才 763次!所以效率大大提高!  到目前第一点降到3.6毫秒、支持5000w数据相似度比较做完了。
    还有第二点同一时刻发出的文本如果重复也只能保留一条和短文本相识度比较怎么解决。其实上面的问题解决了,这两个就不是什么问题了。  之前的评估一直都是按照线性计算来估计的,
    就算有多线程提交相似度计算比较,我们提供相似度计算服务器也需要线性计算。比如同时客户端发送过来两条需要比较相似度的请求,在服务器这边都进行了一个排队处理,一个接着一个,
    第一个处理完了在处理第二个,等到第一个处理完了也就加入了simhash库。所以只要服务端加了队列,就不存在同时请求不能判断的情况。 simhash如何处理短文本?换一种思路,
    simhash可以作为局部敏感哈希第一次计算缩小整个比较的范围,等到我们只有比较700多次比较时,就算使用我们之前精准度高计算很慢的编辑距离也可以搞定。当然如果觉得慢了,也可以使用余弦夹角等效率稍微高点的相似度算法";
     */

    /*--------------------------------------分词说明--------------------------------------
    分词,把需要判断文本分词形成这个文章的特征单词。 最后形成去掉噪音词的单词序列并为每个词加上权重,我们假设权重分为5个级别(1~5)。
    只要相似的字符串只有个别的位数是有差别变化。那这样我们可以推断两个相似的文本, 比如:“ 美国“51区”雇员称内部有9架飞碟,曾看见灰色外星人 ” ==>
    分词后为 “ 美国(4) 51区(5) 雇员(3) 称(1) 内部(2) 有(1) 9架(3) 飞碟(5) 曾(1) 看见(3) 灰色(4) 外星人(5)”, 括号里是代表单词在
    整个句子里重要程度,数字越大越重要。 2、hash,通过hash算法把每个词变成hash值, 比如“美国”通过hash算法计算为 100101, “51区”通过hash算法计算为 101011。
    这样我们的字符串就变成了一串串数字,还记得文章开头说过的吗,要把文章变为数字计算才能提高相似度计算性能,现在是降维过程进行时。 3、加权,通过 2步骤的hash生成结果,
    需要按照单词的权重形成加权数字串, 比如“美国”的hash值为“100101”,通过加权计算为“4 -4 -4 4 -4 4”;“51区”的hash值为“101011”,通过加权计算为 “ 5 -5 5 -5 5 5”。 4、合并,
    把上面各个单词算出来的序列值累加,变成只有一个序列串。 比如 “美国”的 “4 -4 -4 4 -4 4”,“51区”的 “ 5 -5 5 -5 5 5”, 把每一位进行累加, “4+5 -4+-5 -4+5 4+-5 -4+5 4+5” ==》 “9 -9 1 -1 1 9”。
    这里作为示例只算了两个单词的,真实计算需要把所有单词的序列串累加。 5、降维,把4步算出来的 “9 -9 1 -1 1 9” 变成 0 1 串,形成我们最终的simhash签名。 如果每一位大于0 记为 1,小于0 是统优先公司";


    /*--------------------------------------敏感哈希说明--------------------------------------
    算法找出可以hash的key值,因为我们使用的simhash是局部敏感哈希,这个算法的特点是只要相似的字 把需要判断文本分词形成这个文章的特征单词。
    最后形成去掉噪音词的只要相似的字符串只有个别的位数是有差别变化。那这样我们可以推断两个相似的文本,单词序分词是代表单词在整个句子里重要程度,数字越大越重要。
    2、hash,通过hash算法把每个词变成hash值, 比如“美国”通过hash算法计算为 100101, “51区”通过hash算法计算为 101011。 这样我们的字符串就变成了一串串数字,
    还记得文章开头说过的吗,要把文章变为数字加权,通过 家可能会有疑问,经过这么多步骤搞这么麻烦,不就是为了得到个 0 1 字符串吗?我直接把这个文本作为字符串输入v较,
    前面16位变成了hash查找。后面的顺序比较的个数是多,用hd5是用于生成唯一签来相差甚远;hashmap也是用于键值对查找,便于快速插入和查找的数据结构。不过我们主要解决的
    是文本相似度计算,要比较的是两个文章是否相识,当然我们降维生成了hashcode也是用于这个目的。看到这里估计大家就明白了,我们使用的sim是这样的,传统hash函数解决的是
    生成唯一值,比如 md5、hashmap等。md5是用于生成唯一签名串,只要稍微多加一个字符md5的两个数字看起来相差甚远;hashmap也是用于键值对查找,便于快速插入和查找的数
    据结构。不过我们主要解决的是文本相似度计算,要比较的是两个文章是否相识,当然我们降维生成了hashcode也是用于这个目的。看到这里估计大家就明白了,我们使用的simhash
    就算把文章中的字符串变成 01 串也还是可以用于计算相似度的,而传统的hashcode却不行。我们可以来做个测试,两个相差只有一个字符的文本串,“你妈妈喊你回家吃饭哦,回家
    罗回家罗” 和 “你妈妈叫你回家吃饭啦,回家罗回家罗”。短文本大量重复信息不会被过滤,是不是";
     */

    /*--------------------------------------过滤说明--------------------------------------
    最后形成去掉噪音词的单词序分词是代表单词在整个句子里重要程度,数字越大越重要。 最后形成去掉噪音词的单词序列并为每个词加上权重 2、hash,通过hash算法把每个词变成hash值,
    比如“美国”通过hash算法计算为 100101, “51区”通过hash算法计算为 101011。 这样我们的字符串就变成了一串串数字,还记得文章开头说过的吗,分为4个16位段的存储空间是单独simhash
    存储空间的4倍。之前算出5000w数据是 382 Mb,扩大4倍1.5G左右,还可以接受:) 要把文章变为数字加权,通过 家可能会有疑问,经过这么多步骤搞这么麻烦,不就是为了得到个 0 1 字符串吗?
    我直接把这个文本作为字符串输入,用hd5是用于生成唯一签来相差甚远;hashmap也是用于键值对查找,便于快速插入和查找的数据结构。不过我们主要解决的是文本相似度计算,要比较的是两个文章是否相识,
    当然我们降维生成了hashcode也是用于这个目的。看到这里估计大家就明白了,我们使用的sim是这样的,传统hash函数解决的是生成唯一值,比如 md5、hashmap等。md5是用于生成唯一签名串,只要稍微多加
    一个字符md5的两个数字看起来相差甚远;hashmap也是用于键值对查找,便于快速插入和查找的数据结构。不过我们主要解决的是文本相似度计算,要比较的是两个文章是否相识,当然我们降维生成了hashcode也
    是用于这个目的。看到这里估计大家就明白了,我们使用的simhash就算把文章中的字符串变成 01 串也还是可以用于计算相似度的,而传统的hashcode却不行。我们可以来做个测试,两个相差只有一个字符的文本
    串,“你妈妈喊你回家吃饭哦,回家罗回家罗” 和 “你妈妈叫你回家吃饭啦,回家罗回家罗”。短文本大量重复信息不会被过滤,";
     */

3. 案例验证

    public static void main(String[] args) {
        String text = "杏南一区";
        List<String> itemList = new LinkedList<String>();
        itemList.add("杏南一区");
        itemList.add("杏南二区");
        itemList.add("杏南三区");
        itemList.add("杏南四区");
        itemList.add("杏南五区");
        itemList.add("杏南六区");
        itemList.add("杏南七区");
        itemList.add("杏南八区");
        itemList.add("杏南九区");
        itemList.add("杏南十区");
        System.out.println("======================================");
        long startTime = System.currentTimeMillis();
        MySimHash hash1 = new MySimHash(text, 64);
        List<Double> list = new LinkedList<Double>();
        for (String str : itemList) {
            MySimHash hash2 = new MySimHash(str, 64);
            //海明距离越小说明越相似
            System.out.println("海明距离:" + hash1.hammingDistance(hash2) + "###" + "文本相似度:" + hash2.getSemblance(hash1));
            list.add(hash2.getSemblance(hash1));
        }
        long endTime = System.currentTimeMillis();
        Double max = Collections.max(list);
        int index = list.indexOf(max);//获取集合下标
        System.out.println("======================================");
        System.out.println("耗时:" + (endTime - startTime));
        System.out.println("相似度集合内容:" + list.toString());
        System.out.println("集合中最大值:" + max + "###" + "集合下标:" + index);
        System.out.println("对比内容:" + text + "###" + "相似度最高:" + itemList.get(index));
    }

4. 验证结果

测试

总结

我是
南国以南i
记录点滴每天成长一点点,学习是永无止境的!转载请附原文链接!!!

参考链接

1.背景

或许你面试的时候被问到了mysql的排序问题

又或许你在学习排序算法的时候想到了数据库的排序是如何实现的呢

下面重点从面试的角度来回答这个问题

2.面试回答

1.普通面试者回答

普通面试者的回答通常是点对点的回答,如下:

MySQL 实现数据的排序主要通过排序算法和索引结构来完成。排序操作是数据库查询中非常常见的操作,尤其是在执行 SELECT 语句时带有 ORDER BY 子句时。下面详细介绍 MySQL 如何实现数据的排序:

1. 使用索引进行排序

当查询中使用的排序键(ORDER BY 子句中的列)上有索引时,MySQL 可以直接利用这个索引来返回有序的数据,而不需要进行额外的排序操作。这可以极大地提高查询效率,因为索引本身就是有序的。

  • 索引扫描:如果查询可以完全通过索引来满足(即覆盖索引),MySQL 可以通过索引扫描直接返回有序的数据行,而无需读取表中的实际数据行。
  • 索引覆盖扫描:当查询列和排序列都包含在索引中时,MySQL 可以仅通过索引来执行查询,这称为索引覆盖扫描。

2. 文件排序(Filesort)

如果查询中的排序键没有索引支持,或者虽然存在索引但 MySQL 决定不使用(例如,因为索引选择性的原因),MySQL 将不得不执行文件排序(Filesort)操作。

  • 内存排序:首先,MySQL 会尝试在内存中完成排序。它读取查询结果集,将它们存储在内存中,并使用排序算法(如快速排序、归并排序等)对它们进行排序。
  • 磁盘排序:如果排序操作所需的数据量超过了 MySQL 的排序缓冲区大小(
    sort_buffer_size
    ),MySQL 将不得不将数据分成多个块,对每块数据进行排序,然后将排序后的块合并成一个有序的结果集。这个过程中,数据将被写入到临时文件中,并可能需要多次磁盘I/O操作,这会导致性能下降。

3. 排序算法

MySQL 在执行排序时,可能会使用多种排序算法,具体取决于数据量、可用内存和其他因素。常用的排序算法包括快速排序、归并排序等。

4. 优化排序操作

为了提高排序操作的效率,可以采取以下一些优化措施:

  • 创建合适的索引:确保在经常用于排序的列上创建索引。
  • 调整排序缓冲区大小:通过调整
    sort_buffer_size
    配置项,为排序操作分配更多的内存,以减少磁盘I/O。
  • 优化查询:尽量使查询能够利用索引,避免全表扫描。
  • 使用 EXPLAIN 分析查询:使用 EXPLAIN 语句来查看查询的执行计划,了解 MySQL 如何执行查询,包括是否使用了索引和是否进行了文件排序。

通过这些方法,可以有效地提高 MySQL 排序操作的性能。

2.成功面试者的回答

面试回答思路:
面试本质:不是点对点回答问题,而是面试官通过提出一个话题(问题),获取到面试者的思维和技术水平,
因此在回答问题时一定要体现出思考过程(包括如何想的和开发经验)和技术深度;
可以围绕如下3个方向回答
1.回答基本定义
2.说说实际生产中的运用
3.引导到自己擅长的技术点上深入探讨

基本定义

从sql层面来说的话实现排序就是在order by 字段,升序或降序,

mysql 服务要实现这个排序功能的话主要是依靠排序算法和索引来实现;

实际生产

在实际开发中我们一般会用主键或创建时间来排序,特别是数据量大的表,

一般不建议使用经常变动的字段来排序,比如更新时间这个字段排序;

为什么呢?这就会涉及到一个字段创建索引后对修改和新增的影响;

我们都知道,索引虽然提高了查询速度,但是在新增和修改的时候效率会降低;

而实际开发中排序的字段一般来说都要创建索引;

索引排序

索引排序的话,又要分为2种情况

1.索引扫描

2.索引覆盖扫描

索引扫描,通过索引排序,然后读取表中的实际行;

索引覆盖扫描:当查询列和排序列都包含在索引中时,MySQL 可以仅通过索引来执行查询,而无需读取表中的实际数据行,这样效率会高得多。

因此,实际开发中我们一般尽量只取需要的字段返回,不要囫囵吞枣每一列都返回,这样不但用不到覆盖索引,而且可能增大磁盘IO.

文件排序(filesort)

如果不是索引字段排序的话,其实就是常说的文件排序(filesort),这时候也要分为2中情况

1.内存排序

2.磁盘排序

内存排序
:顾名思义就是把数据读取到内存中进行排序,使用排序算法进行排序,但是如果数据量大呢,内存放不下,会出现什么情况呢?内存溢出,报错

当然不会,mysql服务还不至于那么傻,内存不够时就会转入磁盘排序

磁盘排序
:如果排序操作所需的数据量超过了 MySQL 的排序缓冲区大小(sort_buffer_size),MySQL 将不得不将数据分成多个块,对每块数据进行排序,

然后将排序后的块合并成一个有序的结果集。这个过程中,数据将被写入到临时文件中,并可能需要多次磁盘I/O操作,这会导致性能下降。

由此可见排序缓冲区这个参数的设置是mysql调优的重要部分

当然,这些都是理论,实际开发中如果发现一条带有排序的sql执行慢,我们应该使用explain来查看具体原因

备注:

1.explain是优化sql很重要的一个工具,这个一定要会....

2.关于排序算法,大家如果之前有研究过的话,可以深入探讨一下

3.总结&评论

上面2种回答方式:

第一种,更偏向余点对点的回答,类似我们读书时候的回答试卷的方式;

第二种,更偏向于把理论之前与实际开发结合回答,并且更注重得出结论的思考过程;

如果你是面试官,你会觉得那种回答更能得到你的青睐呢?

欢迎在评论区给出你的观点!

完美

响应 Pointer 交互事件(上篇)

上一章我们分析了 sprite 在 canvasRenderer 上的渲染,那么接下来得看看交互上最重要的事件系统了

image

最简单的 demo

还是用一个最简单的 demo 演示
example/sprite-pointerdown.html

为 sprite 添加一个 pointerdown 事件,即点击事件,移动设备上就是 touch 事件, desktop 设备上即 click 事件

const app = new PIXI.Application({ width: 800, height: 600, autoStart: false });  
document.body.appendChild(app.view);  

const sprite = PIXI.Sprite.from('logo.png');

sprite.on('pointerdown', ()=> {
    console.log('clicked')
})

app.stage.addChild(sprite);  
app.start()

试着用鼠标点击 sprite ,会发现控制台并未输出期望的 'clicked'

奇奇怪怪... 看下官网的例子,需要为 sprite 添加
sprite.eventMode = 'static';
;

再运行,就可以看到控制台正常输出 'clicked' 了

显示对象没有自己的事件

Canvas 本身不像 DOM 那样每个元素都有自带的事件的系统用于响应事件

需要自己实现事件系统,可交互的元素都应该是 DisplayObject 及继承自它的子类元素

/packages/display/src/DisplayObject.ts
第 210 行

export abstract class DisplayObject extends utils.EventEmitter<DisplayObjectEvents>

说明,DisplayObject 继承了 EventEmitter 类,因此就有了自定义的事件系统,所有对应的 API

eventemitter3:
https://github.com/primus/eventemitter3

eventemitter3 的 REAMDME 过于简单

得看它的 测试用例
https://github.com/primus/eventemitter3/blob/master/test/test.js

可以发现 监听事件可以用 on, 触发事件可以用 emit

所以 PixiJS 中的 DisplayObject 类实例对象就可以用 on 监听事件,用 emit 触发事件,即
有了自定义事件的能力

当显示对象有了自定义事件能力后,需要一个事件管理系统来管理显示对象的事件触发、监听、移除

来看看 EventSystem 类

/packages/events/src/EventSystem.ts
204 -238 行

constructor(renderer: IRenderer)
  {
    this.renderer = renderer;
    this.rootBoundary = new EventBoundary(null);
    EventsTicker.init(this);

    this.autoPreventDefault = true;
    this.eventsAdded = false;

    this.rootPointerEvent = new FederatedPointerEvent(null);
    this.rootWheelEvent = new FederatedWheelEvent(null);

    this.cursorStyles = {
        default: 'inherit',
        pointer: 'pointer',
    };

    this.features = new Proxy({ ...EventSystem.defaultEventFeatures }, {
        set: (target, key, value) =>
        {
            if (key === 'globalMove')
            {
                this.rootBoundary.enableGlobalMoveEvents = value;
            }
            target[key as keyof EventSystemFeatures] = value;

            return true;
        }
    });
    this.onPointerDown = this.onPointerDown.bind(this);
    this.onPointerMove = this.onPointerMove.bind(this);
    this.onPointerUp = this.onPointerUp.bind(this);
    this.onPointerOverOut = this.onPointerOverOut.bind(this);
    this.onWheel = this.onWheel.bind(this);
  }

EventSystem.ts 的最后一行 extensions.add(EventSystem); 会将它以扩展插件的方式集成到 pixiJS 内

可以看到构造函数内很简单,

  1. 传入了渲染器实例

  2. rootBoudary “根边界” 这个对象很重要,后面会具体介绍

  3. 单独创建一个 ticker 用于管理事件,确保运行状态下显示对象的碰撞检测事件

  4. 实例化了两个事件对象,用于触发时传递,事件对象内的数据结构

  5. onPointerDown/onPointerMove/onPointerUp/onPointerOverOut/onWheel 等绑定到当前 this 上

当 EventSystem 加入 PixiJS 管理后,会被触发 'init' 这个 Runner , 可理解这个 init 生命周期函数被触发

在 EventSystem.ts 第 245 - 254 行:

init(options: EventSystemOptions): void
{
    const { view, resolution } = this.renderer;

    this.setTargetElement(view as HTMLCanvasElement);
    this.resolution = resolution;
    EventSystem._defaultEventMode = options.eventMode ?? 'auto';
    Object.assign(this.features, options.eventFeatures ?? {});
    this.rootBoundary.enableGlobalMoveEvents = this.features.globalMove;
}

可以看到,setTargetElement 用于设置事件目标元素,就是 渲染器对应的 view, 可以认为这个 view 就是 canvas 本身,它是可以响应浏览器的 DOM 事件的, 当然包括,鼠标的点击,移动 等。

setTargetElement 函数 最终会调到 addEvents()

在 EventSystem.ts 第 483 - 546 行:

private addEvents(): void
    {
      ... 省略部分源码
        if (this.supportsPointerEvents)
        {
            globalThis.document.addEventListener('pointermove', this.onPointerMove, true);
            this.domElement.addEventListener('pointerdown', this.onPointerDown, true);
            ... 省略部分源码
            globalThis.addEventListener('pointerup', this.onPointerUp, true);
        }
        else
        {
            globalThis.document.addEventListener('mousemove', this.onPointerMove, true);
            this.domElement.addEventListener('mousedown', this.onPointerDown, true);
            ... 省略部分源码

            if (this.supportsTouchEvents)
            {
                this.domElement.addEventListener('touchstart', this.onPointerDown, true);
               ... 省略部分源码
            }
        }

        this.domElement.addEventListener('wheel', this.onWheel, {
            passive: true,
            capture: true,
        });

        this.eventsAdded = true;
    }

此函数是真正为根元素(或者说是整个 canvas 内自定义事件发起事件的元素)添加事件监听器的地方。

如果支持 pointer 事件则使用 pointer 事件,

如果不支持 pointer 事件则使用 mouse 事件。

如果支持 touch 事件则也要添加上 touch 事件

注意 move 相关的事件是添加在 document 元素上的

至此,当用户点击 canvas 元素时,就相关的回调函数就会执行,如注册的 this.onPointerDown、this.onPointerUp 等

触发的回调函数内会去触发 eventemitter3 的自定义事件。

还是以我们在 sprite-pointer.html 中的例子为例,我们注册了 sprite-pointer.html 中 sprite.on('pointerdown', function() {}),那么当用户点击 canvas 元素时,就会触发这个回调函数。

在 onPointerDown

在 EventSystem.ts 第 343 - 377 行:

private onPointerDown(nativeEvent: MouseEvent | PointerEvent | TouchEvent): void
{
... 省略部分源码
this.rootBoundary.rootTarget = this.renderer.lastObjectRendered as DisplayObject;

const events = this.normalizeToPointerData(nativeEvent);
... 省略部分源码

for (let i = 0, j = events.length; i < j; i++)
{
    const nativeEvent = events[i];
    const federatedEvent = this.bootstrapEvent(this.rootPointerEvent, nativeEvent);

    this.rootBoundary.mapEvent(federatedEvent);
}

... 省略部分

}

  1. 先指定当前 rootBoundary.rootTarget = this.renderer.lastObjectRendered 即响应事件的目标对象"为渲染器最上层的一个显示对象"

  2. 适配浏览器原生事件 nativeEvent 后,调用 this.rootBoundary.mapEvent(federatedEvent), federatedEvent 即标准化为 PixiJS 自定义事件

事件边界 EventBoundary

canvas 内绘制的元素要准确的响应用户点击的操作,必须先确定用户点击的范围在哪里,然后将范围内的 DisplayObject 显示元素触发对应用户绑定的点击事件回调

/packages/events/src/EventBoundary.ts

EventBoundary.ts 构造函数 149 - 172 行 :

constructor(rootTarget?: DisplayObject)
  {
      this.rootTarget = rootTarget;

      this.hitPruneFn = this.hitPruneFn.bind(this);
      this.hitTestFn = this.hitTestFn.bind(this);
      this.mapPointerDown = this.mapPointerDown.bind(this);
      this.mapPointerMove = this.mapPointerMove.bind(this);
      this.mapPointerOut = this.mapPointerOut.bind(this);
      this.mapPointerOver = this.mapPointerOver.bind(this);
      this.mapPointerUp = this.mapPointerUp.bind(this);
      this.mapPointerUpOutside = this.mapPointerUpOutside.bind(this);
      this.mapWheel = this.mapWheel.bind(this);

      this.mappingTable = {};
      this.addEventMapping('pointerdown', this.mapPointerDown);
      this.addEventMapping('pointermove', this.mapPointerMove);
      this.addEventMapping('pointerout', this.mapPointerOut);
      this.addEventMapping('pointerleave', this.mapPointerOut);
      this.addEventMapping('pointerover', this.mapPointerOver);
      this.addEventMapping('pointerup', this.mapPointerUp);
      this.addEventMapping('pointerupoutside', this.mapPointerUpOutside);
      this.addEventMapping('wheel', this.mapWheel);
  }

构造函数内表明实例化后, 由 addEventMapping 方法 将pointerdown,pointermove, pointerout .... 等 8 类事件的回调映射函数保存在了
mappingTable
对象内

后续使用过程中用户添加到显示对象上的交互事件,都会被存储到对应的这 8 类事件列表中

当鼠标点击例子中的 sprite 显示对象时,这个 mapPointerDown 会被触发

EventBoundary.ts 构造函数 672 - 701 行 :

protected mapPointerDown(from: FederatedEvent): void
{
    if (!(from instanceof FederatedPointerEvent))
    {
        console.warn('EventBoundary cannot map a non-pointer event as a pointer event');

        return;
    }

    const e = this.createPointerEvent(from);
    console.log(e.target)
    this.dispatchEvent(e, 'pointerdown');

    if (e.pointerType === 'touch')
    {
        this.dispatchEvent(e, 'touchstart');
    }
    else if (e.pointerType === 'mouse' || e.pointerType === 'pen')
    {
        const isRightButton = e.button === 2;

        this.dispatchEvent(e, isRightButton ? 'rightdown' : 'mousedown');
    }

    const trackingData = this.trackingData(from.pointerId);

    trackingData.pressTargetsByButton[from.button] = e.composedPath();

    this.freeEvent(e);
}

当 view 被点击后,先创建事件对象,然后向目标对象发送事件,接下来就是找到那个目标对象了

找到目标对象即点击的对象

把 mapPointerDown 函数的
const e = this.createPointerEvent(from);
的事件对象打出来看看

image

图 4-1

果然 e.target 把当前点击的就是 sprite,显然在这一步确定了
当前点击的对象
this.createPointerEvent(from);
方法调用非常重要

createPointerEvent 该当在 EventBoundary.ts 文件的 1181 - 1205 行 :

protected createPointerEvent(
    from: FederatedPointerEvent,
    type?: string,
    target?: FederatedEventTarget
): FederatedPointerEvent
{
    const event = this.allocateEvent(FederatedPointerEvent);

    this.copyPointerData(from, event);
    this.copyMouseData(from, event);
    this.copyData(from, event);

    event.nativeEvent = from.nativeEvent;
    event.originalEvent = from;
    event.target = target
        ?? this.hitTest(event.global.x, event.global.y) as FederatedEventTarget
        ?? this._hitElements[0];

    if (typeof type === 'string')
    {
        event.type = type;
    }

    return event;
}

可以看到正是在这个
createPointerEvent
方法内调用 hitTest 或 _hitElements

注意 mapPointerDown 方法内调用
const e = this.createPointerEvent(from);
时只传了一个参数 from

所以此处 target 的确定就是由
this.hitTest(event.global.x, event.global.y) as FederatedEventTarget
来决定的

向 hitTest 方法 传入了当前事件的全局的 x, y 坐标

在 EventBoundary.ts 文件的 247 - 265 行 :

public hitTest(
    x: number,
    y: number,
): DisplayObject
{
    EventsTicker.pauseUpdate = true;
    // if we are using global move events, we need to hit test the whole scene graph
    const useMove = this._isPointerMoveEvent && this.enableGlobalMoveEvents;
    const fn = useMove ? 'hitTestMoveRecursive' : 'hitTestRecursive';
    console.log(this.rootTarget)
    const invertedPath = this[fn](
        this.rootTarget,
        this.rootTarget.eventMode,
        tempHitLocation.set(x, y),
        this.hitTestFn,
        this.hitPruneFn,
    );

    return invertedPath && invertedPath[0];
}

由于对 move 事件需要特殊处理,所以需要判断 在 hitTest 函数内调用了 hitTestMoveRecursive || hitTestRecursive

我们当前 demo 中用的是点击事件,所以调用的会是 hitTestRecursive

把 this.rootTarget 打印出来看看

image

图 4-2

可以看到图 4-2 当前 rootTarget 是一个 container 对象

当点击事件发生时,需要判断的不止是当前对象,而是当前 container 下的所有子对象,所以才需要用到 hitTestRecursive 即是递归判断

递归遍历

hitTestRecursive 函数的最生两个参数分别是用于具体碰撞检测的 hitTestFn 函数 和 用于判断是否可剔除用于碰撞判断的 hitPruneFn 函数

在 EventBoundary.ts 文件的 407 - 539 行 :

protected hitTestRecursive(
    currentTarget: DisplayObject,
    eventMode: EventMode,
    location: Point,
    testFn: (object: DisplayObject, pt: Point) => boolean,
    pruneFn?: (object: DisplayObject, pt: Point) => boolean
): DisplayObject[]
{
    // Attempt to prune this DisplayObject and its subtree as an optimization.
    if (this._interactivePrune(currentTarget) || pruneFn(currentTarget, location))
    {
        return null;
    }

    if (currentTarget.eventMode === 'dynamic' || eventMode === 'dynamic')
    {
        EventsTicker.pauseUpdate = false;
    }

    // Find a child that passes the hit testing and return one, if any.
    if (currentTarget.interactiveChildren && currentTarget.children)
    {
        const children = currentTarget.children;

        for (let i = children.length - 1; i >= 0; i--)
        {
            const child = children[i] as DisplayObject;

            const nestedHit = this.hitTestRecursive(
                child,
                this._isInteractive(eventMode) ? eventMode : child.eventMode,
                location,
                testFn,
                pruneFn
            );

            if (nestedHit)
            {
                // Its a good idea to check if a child has lost its parent.
                // this means it has been removed whilst looping so its best
                if (nestedHit.length > 0 && !nestedHit[nestedHit.length - 1].parent)
                {
                    continue;
                }

                // Only add the current hit-test target to the hit-test chain if the chain
                // has already started (i.e. the event target has been found) or if the current
                // target is interactive (i.e. it becomes the event target).
                const isInteractive = currentTarget.isInteractive();

                if (nestedHit.length > 0 || isInteractive) nestedHit.push(currentTarget);

                return nestedHit;
            }
        }
    }

    const isInteractiveMode = this._isInteractive(eventMode);
    const isInteractiveTarget = currentTarget.isInteractive();

    // Finally, hit test this DisplayObject itself.
    if (isInteractiveMode && testFn(currentTarget, location))
    {
        // The current hit-test target is the event's target only if it is interactive. Otherwise,
        // the first interactive ancestor will be the event's target.
        return isInteractiveTarget ? [currentTarget] : [];
    }

    return null;
}

函数大致流程

  1. 进来先判断是否需要进行碰撞检测
    if (this._interactivePrune(currentTarget) || pruneFn(currentTarget, location))

    剔除需要进行碰撞检测的对象,比如遮罩、不可见、不可交互、无需渲染等对象

  2. 如果有子显示对象,则需要循环所有子显示对象,并递归检测子显示对象

  3. 如果碰撞检测成功(点击位置有子显示对象)则在返回的 nextedHit 数组内把 currentTarget 添加进队尾, 并返回 nestedHit 数组

    if (nestedHit.length > 0 || isInteractive) nestedHit.push(currentTarget);
    return nestedHit;
    
  4. 最后 如果是 isInteractiveMode 并且 testFn 碰撞检测成功,则把当前碰撞对象放到数组内返回

注意在这个函数内的这一行
const isInteractiveMode = this._isInteractive(eventMode);

在 EventBoundary.ts 文件的 541 -544 行 :

private _isInteractive(int: EventMode): int is 'static' | 'dynamic'
{
    return int === 'static' || int === 'dynamic';
}

到这里终于知道我们 demo 中当没有指定 eventMode 为 static 或 dynamic 时,没有响应点击事件的原因了

这是检测 rootTarget 父级对象的, 如果父级对象比如 container 都不支持交互了,就不必再对其子显示对象进行碰撞检测了

还有
const isInteractiveTarget = currentTarget.isInteractive();
这一行,判断元素本身是否可交互 也是判断 eventMode 这是检测当前 target 的


/packages/events/src/FederatedEventTarget.ts
事件定义内 657 - 660 行:

isInteractive()
{
    return this.eventMode === 'static' || this.eventMode === 'dynamic';
},

接下来就要用碰撞检测来检测是否是点击对象了

找到碰撞检测函数

注意看最后的 testFn 即传入来的 hitTestFn 碰撞检测函数

在 EventBoundary.ts 文件的 615 - 637 行 :

protected hitTestFn(displayObject: DisplayObject, location: Point): boolean
{
    // If the displayObject is passive then it cannot be hit directly.
    if (displayObject.eventMode === 'passive')
    {
        return false;
    }

    // If the display object failed pruning with a hitArea, then it must pass it.
    if (displayObject.hitArea)
    {
        return true;
    }

    if ((displayObject as any).containsPoint)
    {
        return (displayObject as any).containsPoint(location) as boolean;
    }

    // TODO: Should we hit test based on bounds?

    return false;
}

主要进行了三个判断

  1. eventMode === 'passive' 直接不进行碰撞检测,用于优化性能,比如在滚动区域内滚动时可以设置内部的元素为 passive

  2. displayObject.hitArea 判断 主要作用


    • 自定义交互区域:你可以定义一个特定的区域来响应用户交互,而不是使用显示对象的整个边界框。这在某些情况下非常有用,例如当你有一个复杂形状的对象,但只希望某个部分响应交互。
    • 提高性能:通过定义较小的交互区域,可以减少不必要的命中测试,从而提高性能。
    • 精确控制:你可以精确控制哪些区域应该响应用户交互,这在游戏开发和复杂的用户界面中非常有用。
  3. displayObject.containsPoint 检测,可以看到,
    containsPoint
    是由显示对象各自自己实现的方法

注意:
GraphicsGeometry.containsPoint 方法,内可知,如果你绘制的是直线、贝塞尔曲线等线条添加鼠标事件是不会起作用的,因为这些只是路径,并不是形状,你需要为这些添加 hitArea 后才交互事件才会起作用

以 sprite 类实现的
containsPoint
举例

/packages/sprite/src/Sprite.ts
第 439 - 459 行:

public containsPoint(point: IPointData): boolean
{
    this.worldTransform.applyInverse(point, tempPoint);

    const width = this._texture.orig.width;
    const height = this._texture.orig.height;
    const x1 = -width * this.anchor.x;
    let y1 = 0;

    if (tempPoint.x >= x1 && tempPoint.x < x1 + width)
    {
        y1 = -height * this.anchor.y;

        if (tempPoint.y >= y1 && tempPoint.y < y1 + height)
        {
            return true;
        }
    }

    return false;
}

sprite 的 containsPoint 判断坐标点是否在显示对象的矩形内比较简单,就是将全局坐标点转换为 sprite 的本地坐标点,然后判断是否在矩形内

this.worldTransform.applyInverse 方法,传入一个坐标点,返回一个由世界坐标转换成本地坐标的新坐标点,这个新坐标点就是 sprite 本地坐标点

如何处理 sprite 叠加时的碰撞检测

如果只是简单的点与形状的碰撞检测,那么如果两个显示对象叠加在一起时,点击上层的显示对像,如果不加处理,叠在下面的对象也会响应点击事件

新建个 demo 演示
example/two-sprite-pointerdown.html

const app = new PIXI.Application({ width: 800, height: 600, autoStart: false });  
document.body.appendChild(app.view);  

const sprite = PIXI.Sprite.from('logo.png');  
sprite.eventMode = 'static';
sprite._Name = 'sprite1';
sprite.on('pointerdown', ()=> {
    console.log('clicked')
})

const sprite2 = PIXI.Sprite.from('logo.png');
sprite2.tint = 'red';
sprite2.eventMode = 'static';
sprite2._Name = 'sprite2';
sprite2.x = 100
sprite2.on('pointerdown', ()=> {
    console.log('clicked2')
})

app.stage.addChild(sprite);  
app.stage.addChild(sprite2);  
app.start()
  1. 其它代码与
    sprite-pointerdown.html
    几乎一样,就是添加了两个 sprite 且有一部分重叠在一起

  2. 分别给这两个 sprite 分别添加了 _Name 属性,方便调试 sprite1 和 sprite2

  3. sprite2 的 tint 属性设置为红色

  4. 修改了 sprite2 的x 值,使得 sprite2 只覆盖一部分 sprite1

如图:4-3

image

图 4-3

在 hitTestRecursive 函数内把 for 循环内的 nestedHit 打印出来

在 EventBoundary.ts 文件的 407 - 539 行 :

protected hitTestRecursive(
    currentTarget: DisplayObject,
    eventMode: EventMode,
    location: Point,
    testFn: (object: DisplayObject, pt: Point) => boolean,
    pruneFn?: (object: DisplayObject, pt: Point) => boolean
): DisplayObject[]
{
    ...省略部分代码
    if (currentTarget.interactiveChildren && currentTarget.children)
    {
        const children = currentTarget.children;

        for (let i = children.length - 1; i >= 0; i--)
        {
            const child = children[i] as DisplayObject;

            const nestedHit = this.hitTestRecursive(
                child,
                this._isInteractive(eventMode) ? eventMode : child.eventMode,
                location,
                testFn,
                pruneFn
            );
            console.log(nestedHit)
            ...省略部分代码
        }
    }
    ...省略部分代码
}

测试,点击与左侧logo重叠的位置右侧红色的 pixijs logo

image

图 4-4

输出结果确实是正确的,并没有把 sprite1 输出,仔细看 for 循环可以发现它是倒序遍历的,也就是添加在最后的显示对象,先响应碰撞

因为后面添加的对象理论上是覆盖在上层,所以应该先响应碰撞

如果把 for 循环的遍历顺序改成正序,那么就会输出 sprite1

...省略部分代码
for (let i = 0; i < children.length; i++)
{
    const child = children[i] as DisplayObject;

    const nestedHit = this.hitTestRecursive(
        child,
        this._isInteractive(eventMode) ? eventMode : child.eventMode,
        location,
        testFn,
        pruneFn
    );
    console.log(nestedHit)
    ...省略部分代码
}

image

图 4-5

可以看到,通过 for 循环的倒序遍历就实现了盖在上层的显示对象优先响应的功能

本章小节

碰撞检测的粗略流程:

EventSystem.ts 的 init -> pointerdown -> onPointerDown -> mapPointerDown -> createPointerEvent -> hitTest -> hitTestRecursive -> hitTestFn -> containsPoint

事件的检测为什么不是像素级:

与 EaselJS 库的像素级碰撞检测不同, PixiJS 采用的是点与形状的碰撞检测

https://github.com/pixijs/pixijs/wiki/v5-Hacks#pixel-perfect-interaction

绕了一圈函数调用,才成功实现碰撞检测,下一篇再关注一下,碰撞检测成功后,
派发事件


注:转载请注明出处博客园:王二狗Sheldon池中物 (willian12345@126.com)