【UE4 C++】四叉树实现及可视化
前言
-
主要参考
-
UE4自带 TQuadTree
- GenericQuadTree.h 和 WaterQuadTree.h
- 源码见附录。写到最后,才发现引擎有这部分源码了,不过没有树的更新。
-
实现版本 4.26.2
-
存在问题
-
不使用完全清除四叉树的方法而直接动态更新,会有bug:这边是运行久了,对象丢失,对象无法复原状态和响应。
-
如果有大佬,看了代码知道为什么,希望指导一下,非常感谢
-
概念
-
四叉树是一种树形数据结构,其每个节点至多有四个子节点,表示将当前空间划分为四个子空间,如此递归下去,直到达到一定深度或者满足某种要求后停止划分。
-
所有的四叉树法有共同之特点:
-
可分解成为各自的区块
-
每个区块都有节点容量。当节点达到最大容量时,节点分裂
-
树状数据结构依造四叉树法加以区分
-
-
应用
-
二维数据查找
-
图像压缩
-
光线求交
-
地形渲染
-
二维的碰撞检测等
-
实现
- 效果
代码
-
树节点
//@ https://www.cnblogs.com/shiroe/p/15526194.html // 四叉树的节点 class QuadTreeNode:public TSharedFromThis<QuadTreeNode> { public: FVector center; // 中心点 FVector extend; // 扩展尺寸 bool isLeaf; //是否是叶子节点 int32 depth = 0; int32 maxCount = 4; TArray<ABattery*>objs; static UObject* worldObject; bool bInRange; TSharedPtr<QuadTreeNode> root; TArray<TSharedPtr<QuadTreeNode>> child_node; public: QuadTreeNode(FVector _center, FVector _extend, int32 _depth, TSharedPtr<QuadTreeNode> _root=nullptr) : center(_center), extend(_extend), depth(_depth) { root = _root; isLeaf = true; child_node.Init(nullptr, 4); bInRange = false; } ~QuadTreeNode() { root = nullptr; objs.Empty(); child_node.Empty(); } bool IsNotUsed() { return isLeaf && objs.Num() <= 0; } //方形与圆形求交 bool InterSection(FVector _OCenter, float _radian) { FVector v = _OCenter - center; //取相对原点 float x = UKismetMathLibrary::Min(v.X, extend.X); x = UKismetMathLibrary::Max(x, -extend.X); float y = UKismetMathLibrary::Min(v.Y, extend.Y); y = UKismetMathLibrary::Max(y, -extend.Y); return (x - v.X) * (x - v.X) + (y - v.Y) * (y - v.Y) <= _radian * _radian; //注意此时圆心的相对坐标 } //点是否在本区域内 bool InterSection(FVector _point) { return (_point.X >= center.X - extend.X && _point.X <= center.X + extend.X && _point.Y >= center.Y - extend.Y && _point.Y <= center.Y + extend.Y); } //点是否在指定区域内 bool InterSection(FVector _pMin, FVector _pMax, FVector _point) { return (_point.X >= _pMin.X && _point.X <= _pMax.X && _point.Y >= _pMin.Y && _point.Y <= _pMax.Y); } //插入对象 void InsertObj(ABattery* obj) { objs.Add(obj); if (isLeaf && objs.Num() <= maxCount) //直接插入 { return; } float dx[4] = { 1, -1, -1, 1 }; float dy[4] = { 1, 1, -1, -1 }; //超过上限个数,创建子节点;或者不再是叶子节点 isLeaf = false; for (auto& item : objs) { for (int i = 0; i < 4; i++) {//四个象限 FVector p = center + FVector(extend.X * dx[i], extend.Y * dy[i], 0); FVector pMin = p.ComponentMin(center); FVector pMax = p.ComponentMax(center); if (InterSection(pMin, pMax, item->GetActorLocation())) { if (!child_node[i].IsValid()) { root = root.IsValid() ? root : this->AsShared(); child_node[i] = MakeShareable(new QuadTreeNode(pMin/2+pMax/2, extend / 2, depth + 1, root)); } child_node[i]->InsertObj(item); //break; //确保只在一个象限内 } } } objs.Empty(); //确保非叶子节点不存 } // 绘制区域边界 void DrawBound(float time = 0.02f, float thickness = 2.0f) { if (worldObject) { FLinearColor drawColor = bInRange ? FLinearColor::Green : FLinearColor::Red; FVector drawCenter = center + (bInRange ? FVector(0, 0, 8) : FVector(0, 0, 5)); UKismetSystemLibrary::DrawDebugBox(worldObject, drawCenter, extend+FVector(0,0,1), drawColor, FRotator::ZeroRotator, time, thickness); } } // 判断电池是否在扫描器的范围类 void TraceObjectInRange(AActor* traceActor, float _radian) { FVector _OCenter = traceActor->GetActorLocation(); if (InterSection(_OCenter, _radian)) { bInRange = true; if (isLeaf) { for (ABattery* obj : objs) { _OCenter.Z = obj->GetActorLocation().Z; bool bCanActive = FVector::Distance(_OCenter, obj->GetActorLocation()) <= _radian; obj->ActiveState(bCanActive, traceActor); } } else { for (auto& node : child_node) { if (node.IsValid()) { node->TraceObjectInRange(traceActor, _radian); } } } } else { TraceObjectOutRange(_OCenter, _radian); } } void TraceObjectOutRange(FVector _OCenter, float _radian) { bInRange = false; for (ABattery* obj : objs){ { obj->ActiveState(false, nullptr); } } for (auto& node: child_node) { if (node.IsValid()) { node->TraceObjectOutRange(_OCenter, _radian); } } } // 更新状态 void UpdateState() { DrawBound(1 / UKismetSystemLibrary::GetFrameCount()); //根据帧数绘制 if (!isLeaf) { //如果不是叶子节点,则递归到子树下去,如果子树为空,则回收该节点 for (auto& node : child_node){ if (node.IsValid()) { node->UpdateState(); if (node->IsNotUsed()) { node.Reset(); node = nullptr; } } } int32 count = 4; for (auto& node : child_node) { if (!node.IsValid()) count--; } if (count == 0) { isLeaf = true; }else return; } if (isLeaf && objs.Num()>0){ //如果叶子节点,更新物体是否在区域内;不在区域则移出,并重新插入 int32 i = 0; while (i<objs.Num()) { if (!InterSection(objs[i]->GetActorLocation())) { ABattery* battery = objs[i]; objs.Swap(i, objs.Num() - 1); objs.Pop(); root->InsertObj(battery); continue; } i++; } } } };
-
树、管理器
-
头文件
//@ https://www.cnblogs.com/shiroe/p/15526194.html UCLASS() class PRIME_API AQuadTree : public AActor //该类可以当成树 { GENERATED_BODY() public: AQuadTree(); protected: virtual void BeginPlay() override; public: virtual void Tick(float DeltaTime) override; void SpawnActors(); void ActorsAddVelocity(); public: UPROPERTY(EditAnywhere) int32 cubeCount=20; UPROPERTY(EditAnywhere) int32 width=500; UPROPERTY(EditAnywhere) int32 height=500; UPROPERTY(EditAnywhere) float playRate=0.05; UPROPERTY(EditAnywhere) TSubclassOf<ABattery> BatteryClass; UPROPERTY(EditAnywhere) AActor* traceActor; UPROPERTY(EditAnywhere) float affectRadianRange=50; UPROPERTY() TArray<ABattery*> objs; TSharedPtr<QuadTreeNode> root; FTimerHandle timer; FTimerHandle timer2; };
-
cpp
//@ https://www.cnblogs.com/shiroe/p/15526194.html AQuadTree::AQuadTree() { // Set this actor to call Tick() every frame. You can turn this off to improve performance if you don't need it. PrimaryActorTick.bCanEverTick = true; } UObject* QuadTreeNode::worldObject=nullptr; void AQuadTree::BeginPlay() { Super::BeginPlay(); QuadTreeNode::worldObject = GetWorld(); root = MakeShareable(new QuadTreeNode(FVector::ZeroVector, FVector(height, width, 0), 0)); GetWorld()->GetTimerManager().SetTimer(timer, this, &AQuadTree::SpawnActors, playRate, true); GetWorld()->GetTimerManager().SetTimer(timer2, this, &AQuadTree::ActorsAddVelocity, 2, true); } void AQuadTree::Tick(float DeltaTime) { Super::Tick(DeltaTime); if (root.IsValid()) { root->UpdateState(); //更新状态 root->TraceObjectInRange(traceActor, affectRadianRange); //判断是否在扫描器的范围内 } } // 定时生成物体 void AQuadTree::SpawnActors() { if (cubeCount < 0) { GetWorld()->GetTimerManager().ClearTimer(timer); return; } cubeCount--; FVector pos = FVector(UKismetMathLibrary::RandomIntegerInRange(-height+10, height-10), UKismetMathLibrary::RandomIntegerInRange(-width+10, width-10), 11); FTransform trans = FTransform(FRotator(0, UKismetMathLibrary::RandomFloatInRange(0, 360), 0), pos, FVector(0.2)); ABattery* actor= GetWorld()->SpawnActor<ABattery>(BatteryClass, trans); if (IsValid(actor)) { objs.Add(actor); root->InsertObj(actor); } } // 定时给物体一个速度 void AQuadTree::ActorsAddVelocity() { for (ABattery* actor :objs) { actor->GetStaticMeshComponent()->SetPhysicsLinearVelocity(UKismetMathLibrary::RandomUnitVector() * 50); } }
-
电池物体类 Battery
-
头文件
//@ https://www.cnblogs.com/shiroe/p/15526194.html UCLASS() class PRIME_API ABattery : public AStaticMeshActor { GENERATED_BODY() public: ABattery(); protected: virtual void BeginPlay() override; public: virtual void Tick(float DeltaTime) override; void ActiveState(bool _bActive, AActor* _targetActor); public: UPROPERTY(EditAnywhere) UMaterial* m_normal; UPROPERTY(EditAnywhere) UMaterial* m_active; UPROPERTY() AActor* targetActor; bool bActive = false; };
-
cpp
//@ https://www.cnblogs.com/shiroe/p/15526194.html #include "Battery.h" #include "Kismet/KismetMathLibrary.h" #include "DrawDebugHelpers.h" #include "Kismet/KismetSystemLibrary.h" ABattery::ABattery() { PrimaryActorTick.bCanEverTick = true; GetStaticMeshComponent()->SetMobility(EComponentMobility::Movable); GetStaticMeshComponent()->SetConstraintMode(EDOFMode::XYPlane); GetStaticMeshComponent()->SetSimulatePhysics(true); } void ABattery::BeginPlay() { Super::BeginPlay(); } void ABattery::Tick(float DeltaTime) { Super::Tick(DeltaTime); if (bActive && targetActor) { float drawTime = 1 / UKismetSystemLibrary::GetFrameCount(); DrawDebugLine(GetWorld(), GetActorLocation(), targetActor->GetActorLocation(), FColor(0,148,220,255), false, drawTime, 1, 4.0f); } } void ABattery::ActiveState(bool _bActive, AActor* _targetActor){ if (bActive == _bActive) return; bActive = _bActive; targetActor = _targetActor; GetStaticMeshComponent()->SetMaterial(0, bActive ? m_active : m_normal); }
-
-
其他
- LevelSequece 设置扫描器的空中移动路径
- 电池物体类 Battery 创建蓝图派生类,用来设置资源
-
附录
-
\Engine\Source\Runtime\Engine\Public\GenericQuadTree.h
点击查看代码
// Copyright Epic Games, Inc. All Rights Reserved. #pragma once #include "CoreMinimal.h" ENGINE_API DECLARE_LOG_CATEGORY_EXTERN(LogQuadTree, Log, Warning); template <typename ElementType, int32 NodeCapacity = 4> class TQuadTree { typedef TQuadTree<ElementType, NodeCapacity> TreeType; public: /** DO NOT USE. This constructor is for internal usage only for hot-reload purposes. */ TQuadTree(); TQuadTree(const FBox2D& InBox, float InMinimumQuadSize = 100.f); /** Gets the TreeBox so systems can test insertions before trying to do so with invalid regions */ const FBox2D& GetTreeBox() const { return TreeBox; } /** Inserts an object of type ElementType with an associated 2D box of size Box (log n). Pass in a DebugContext so when an issue occurs the log can report what requested this insert. */ void Insert(const ElementType& Element, const FBox2D& Box, const TCHAR* DebugContext = nullptr); /** Given a 2D box, returns an array of elements within the box. There will not be any duplicates in the list. */ template<typename ElementAllocatorType> void GetElements(const FBox2D& Box, TArray<ElementType, ElementAllocatorType>& ElementsOut) const; /** Removes an object of type ElementType with an associated 2D box of size Box (log n). Does not cleanup tree*/ bool Remove(const ElementType& Instance, const FBox2D& Box); /** Does a deep copy of the tree by going through and re-creating the internal data. Cheaper than re-insertion as it should be linear instead of nlogn */ void Duplicate(TreeType& OutDuplicate) const; /** Removes all elements of the tree */ void Empty(); void Serialize(FArchive& Ar); TreeType& operator=(const TreeType& Other); ~TQuadTree(); private: enum QuadNames { TopLeft = 0, TopRight = 1, BottomLeft = 2, BottomRight = 3 }; /** Node used to hold the element and its corresponding 2D box*/ struct FNode { FBox2D Box; ElementType Element; FNode() {}; FNode(const ElementType& InElement, const FBox2D& InBox) : Box(InBox) , Element(InElement) {} friend FArchive& operator<<(FArchive& Ar, typename TQuadTree<ElementType, NodeCapacity>::FNode& Node) { return Ar << Node.Box << Node.Element; } }; /** Given a 2D box, return the subtrees that are touched. Returns 0 for leaves. */ int32 GetQuads(const FBox2D& Box, TreeType* Quads[4]) const; /** Split the tree into 4 sub-trees */ void Split(); /** Given a list of nodes, return which ones actually intersect the box */ template<typename ElementAllocatorType> void GetIntersectingElements(const FBox2D& Box, TArray<ElementType, ElementAllocatorType>& ElementsOut) const; /** Given a list of nodes, remove the node that contains the given element */ bool RemoveNodeForElement(const ElementType& Element); /** Internal recursive implementation of @see Insert */ void InsertElementRecursive(const ElementType& Element, const FBox2D& Box, const TCHAR* DebugContext); private: /** * Contains the actual elements this tree is responsible for. Nodes are used to keep track of each element's AABB as well. * For a non-internal leaf, this is the list of nodes that are fully contained within this tree. * For an internal tree, this contains the nodes that overlap multiple subtrees. */ TArray<FNode> Nodes; /** The sub-trees of this tree */ TreeType* SubTrees[4]; /** AABB of the tree */ FBox2D TreeBox; /** Center position of the tree */ FVector2D Position; /** The smallest size of a quad allowed in the tree */ float MinimumQuadSize; /** Whether this is a leaf or an internal sub-tree */ bool bInternal; }; template <typename ElementType, int32 NodeCapacity /*= 4*/> typename TQuadTree<ElementType, NodeCapacity>::TreeType& TQuadTree<ElementType, NodeCapacity>::operator=(const TreeType& Other) { Other.Duplicate(*this); return *this; } template <typename ElementType, int32 NodeCapacity /*= 4*/> void TQuadTree<ElementType, NodeCapacity>::Serialize(FArchive& Ar) { Ar << Nodes; bool SubTreeFlags[4] = { SubTrees[0] != nullptr, SubTrees[1] != nullptr, SubTrees[2] != nullptr, SubTrees[3] != nullptr }; Ar << SubTreeFlags[0] << SubTreeFlags[1] << SubTreeFlags[2] << SubTreeFlags[3]; for (int32 Idx = 0; Idx < 4; ++Idx) { if (SubTreeFlags[Idx]) { if (Ar.IsLoading()) { SubTrees[Idx] = new TreeType(FBox2D(), MinimumQuadSize); } SubTrees[Idx]->Serialize(Ar); } } Ar << TreeBox; Ar << Position; Ar << bInternal; } template <typename ElementType, int32 NodeCapacity> TQuadTree<ElementType, NodeCapacity>::TQuadTree(const FBox2D& Box, float InMinimumQuadSize) : TreeBox(Box) , Position(Box.GetCenter()) , MinimumQuadSize(InMinimumQuadSize) , bInternal(false) { SubTrees[0] = SubTrees[1] = SubTrees[2] = SubTrees[3] = nullptr; } template <typename ElementType, int32 NodeCapacity> TQuadTree<ElementType, NodeCapacity>::TQuadTree() { EnsureRetrievingVTablePtrDuringCtor(TEXT("TQuadTree()")); } template <typename ElementType, int32 NodeCapacity> TQuadTree<ElementType, NodeCapacity>::~TQuadTree() { for (TreeType* SubTree : SubTrees) { delete SubTree; SubTree = nullptr; } } template <typename ElementType, int32 NodeCapacity> void TQuadTree<ElementType, NodeCapacity>::Split() { check(bInternal == false); const FVector2D Extent = TreeBox.GetExtent(); const FVector2D XExtent = FVector2D(Extent.X, 0.f); const FVector2D YExtent = FVector2D(0.f, Extent.Y); /************************************************************************ * ___________max * | | | * | | | * |-----c------ * | | | * min___|_____| * * We create new quads by adding xExtent and yExtent ************************************************************************/ const FVector2D C = Position; const FVector2D TM = C + YExtent; const FVector2D ML = C - XExtent; const FVector2D MR = C + XExtent; const FVector2D BM = C - YExtent; const FVector2D BL = TreeBox.Min; const FVector2D TR = TreeBox.Max; SubTrees[TopLeft] = new TreeType(FBox2D(ML, TM), MinimumQuadSize); SubTrees[TopRight] = new TreeType(FBox2D(C, TR), MinimumQuadSize); SubTrees[BottomLeft] = new TreeType(FBox2D(BL, C), MinimumQuadSize); SubTrees[BottomRight] = new TreeType(FBox2D(BM, MR), MinimumQuadSize); //mark as no longer a leaf bInternal = true; // Place existing nodes and place them into the new subtrees that contain them // If a node overlaps multiple subtrees, we retain the reference to it here in this quad TArray<FNode> OverlappingNodes; for (const FNode& Node : Nodes) { TreeType* Quads[4]; const int32 NumQuads = GetQuads(Node.Box, Quads); check(NumQuads > 0); if (NumQuads == 1) { Quads[0]->Nodes.Add(Node); } else { OverlappingNodes.Add(Node); } } // Hang onto the nodes that don't fit cleanly into a single subtree Nodes = OverlappingNodes; } template <typename ElementType, int32 NodeCapacity> void TQuadTree<ElementType, NodeCapacity>::Insert(const ElementType& Element, const FBox2D& Box, const TCHAR* DebugContext) { if (!Box.Intersect(TreeBox)) { // Elements shouldn't be added outside the bounds of the top-level quad UE_LOG(LogQuadTree, Warning, TEXT("[%s] Adding element (%s) that is outside the bounds of the quadtree root (%s). Consider resizing."), DebugContext ? DebugContext : TEXT("Unknown Source"), *Box.ToString(), *TreeBox.ToString()); } InsertElementRecursive(Element, Box, DebugContext); } template <typename ElementType, int32 NodeCapacity> void TQuadTree<ElementType, NodeCapacity>::InsertElementRecursive(const ElementType& Element, const FBox2D& Box, const TCHAR* DebugContext) { TreeType* Quads[4]; const int32 NumQuads = GetQuads(Box, Quads); if (NumQuads == 0) { // This should only happen for leaves check(!bInternal); // It's possible that all elements in the leaf are bigger than the leaf or that more elements than NodeCapacity exist outside the top level quad // In either case, we can get into an endless spiral of splitting const bool bCanSplitTree = TreeBox.GetSize().SizeSquared() > FMath::Square(MinimumQuadSize); if (!bCanSplitTree || Nodes.Num() < NodeCapacity) { Nodes.Add(FNode(Element, Box)); if (!bCanSplitTree) { UE_LOG(LogQuadTree, Verbose, TEXT("[%s] Minimum size %f reached for quadtree at %s. Filling beyond capacity %d to %d"), DebugContext ? DebugContext : TEXT("Unknown Source"), MinimumQuadSize, *Position.ToString(), NodeCapacity, Nodes.Num()); } } else { // This quad is at capacity, so split and try again Split(); InsertElementRecursive(Element, Box, DebugContext); } } else if (NumQuads == 1) { check(bInternal); // Fully contained in a single subtree, so insert it there Quads[0]->InsertElementRecursive(Element, Box, DebugContext); } else { // Overlaps multiple subtrees, store here check(bInternal); Nodes.Add(FNode(Element, Box)); } } template <typename ElementType, int32 NodeCapacity> bool TQuadTree<ElementType, NodeCapacity>::RemoveNodeForElement(const ElementType& Element) { int32 ElementIdx = INDEX_NONE; for (int32 NodeIdx = 0, NumNodes = Nodes.Num(); NodeIdx < NumNodes; ++NodeIdx) { if (Nodes[NodeIdx].Element == Element) { ElementIdx = NodeIdx; break; } } if (ElementIdx != INDEX_NONE) { Nodes.RemoveAtSwap(ElementIdx, 1, false); return true; } return false; } template <typename ElementType, int32 NodeCapacity> bool TQuadTree<ElementType, NodeCapacity>::Remove(const ElementType& Element, const FBox2D& Box) { bool bElementRemoved = false; TreeType* Quads[4]; const int32 NumQuads = GetQuads(Box, Quads); // Remove from nodes referenced by this quad bElementRemoved = RemoveNodeForElement(Element); // Try to remove from subtrees if necessary for (int32 QuadIndex = 0; QuadIndex < NumQuads && !bElementRemoved; QuadIndex++) { bElementRemoved = Quads[QuadIndex]->Remove(Element, Box); } return bElementRemoved; } template <typename ElementType, int32 NodeCapacity> template <typename ElementAllocatorType> void TQuadTree<ElementType, NodeCapacity>::GetElements(const FBox2D& Box, TArray<ElementType, ElementAllocatorType>& ElementsOut) const { TreeType* Quads[4]; const int32 NumQuads = GetQuads(Box, Quads); // Always include any nodes contained in this quad GetIntersectingElements(Box, ElementsOut); // As well as all relevant subtrees for (int32 QuadIndex = 0; QuadIndex < NumQuads; QuadIndex++) { Quads[QuadIndex]->GetElements(Box, ElementsOut); } } template <typename ElementType, int32 NodeCapacity> template <typename ElementAllocatorType> void TQuadTree<ElementType, NodeCapacity>::GetIntersectingElements(const FBox2D& Box, TArray<ElementType, ElementAllocatorType>& ElementsOut) const { ElementsOut.Reserve(ElementsOut.Num() + Nodes.Num()); for (const FNode& Node : Nodes) { if (Box.Intersect(Node.Box)) { //Debug performance will be at least 1 order slower checkSlow(!ElementsOut.Contains(Node.Element)); ElementsOut.Add(Node.Element); } }; } template <typename ElementType, int32 NodeCapacity> int32 TQuadTree<ElementType, NodeCapacity>::GetQuads(const FBox2D& Box, TreeType* Quads[4]) const { int32 QuadCount = 0; if (bInternal) { bool bNegX = Box.Min.X <= Position.X; bool bNegY = Box.Min.Y <= Position.Y; bool bPosX = Box.Max.X >= Position.X; bool bPosY = Box.Max.Y >= Position.Y; if (bNegX && bNegY) { Quads[QuadCount++] = SubTrees[BottomLeft]; } if (bPosX && bNegY) { Quads[QuadCount++] = SubTrees[BottomRight]; } if (bNegX && bPosY) { Quads[QuadCount++] = SubTrees[TopLeft]; } if (bPosX && bPosY) { Quads[QuadCount++] = SubTrees[TopRight]; } } return QuadCount; } template <typename ElementType, int32 NodeCapacity> void TQuadTree<ElementType, NodeCapacity>::Duplicate(TreeType& OutDuplicate) const { for (int32 TreeIdx = 0; TreeIdx < 4; ++TreeIdx) { if (TreeType* SubTree = SubTrees[TreeIdx]) { OutDuplicate.SubTrees[TreeIdx] = new TreeType(FBox2D(0, 0), MinimumQuadSize); SubTree->Duplicate(*OutDuplicate.SubTrees[TreeIdx]); //duplicate sub trees } } OutDuplicate.Nodes = Nodes; OutDuplicate.TreeBox = TreeBox; OutDuplicate.Position = Position; OutDuplicate.MinimumQuadSize = MinimumQuadSize; OutDuplicate.bInternal = bInternal; } template <typename ElementType, int32 NodeCapacity> void TQuadTree<ElementType, NodeCapacity>::Empty() { for (int32 TreeIdx = 0; TreeIdx < 4; ++TreeIdx) { if (TreeType* SubTree = SubTrees[TreeIdx]) { delete SubTree; SubTrees[TreeIdx] = nullptr; } } Nodes.Empty(); bInternal = false; }
作者:砥才人
出处:https://www.cnblogs.com/shiroe
本系列文章为笔者整理原创,只发表在博客园上,欢迎分享本文链接,如需转载,请注明出处!