UE4移动组件详解(二)——移动同步机制_pending move-程序员宅基地

技术标签: 同步机制  移动  RPC  UE4  移动同步  Unreal Engine 4(虚幻引擎)  游戏开发  

第一部分从移动相关架构以及单机情况下移动的处理细节讲起 UE4移动组件详解(一)——移动框架与实现原理
而第二部分是关于移动组件同步解决方案的描述,里面有诸多细节来让移动的同步表现的更为流畅。关于移动网络同步这一块内容,博主还有一些地方还没有完全梳理清楚,会在之后的时间里慢慢完善。

四.移动同步解决方案

前面关于移动逻辑的细节处理都是在PerformMovement里面实现的,我们可以把函数PerformMovement当成一个完整的移动处理流程。这个流程无论是在客户端还是在服务器都必须要执行,或者作为一个单机游戏,这一个接口基本上可以满足我们的正常移动了。不过,在网络游戏中,为了让所有的玩家体验一个几乎相同的世界,需要保证一个具有绝对权威的服务器,这个服务器可以修正客户端的不正常移动行为,保证各个客户端的一致性。相关同步的操作都是基于UCharacterMovement组件实现的,所以我们的角色必须要使用这个移动组件。

移动组件的同步全都是基于RPC不可靠传输的,你会在UCharacterMovement头文件里面看到多个以Server或者Client开头的RPC函数。

关于移动组件的同步思路,建议选阅读一下官方文档的内容,https://docs.unrealengine.com/latest/CHN/Gameplay/Networking/CharacterMovementComponent/index.html 回头看可能更为清晰一点。现在我们把整个移动细节作为一个接口封装起来,宏观的研究移动组件的同步细节。

另外,如果还没有完全搞清ROLE_Authority,ROLE_AutonomousProxy,ROLE_SimulatedProxy的概念,请参考 UE4网络同步详解(一)——理解同步规则。这里举个例子,一个服务器上有一个玩家ServerA和一个NPC ServerB,客户端上拥有从服务器复制过来的这个玩家ClientA与NPC ClientB。由于ServerA与ServerB都是在服务器上生成的,所以他们两在服务器上的所有权Role都是ROLE_Authority。ClientA在客户端上由于被玩家控制,他的Role是ROLE_AutonomousProxy。ClientB在客户端是完全通过服务器同步来控制的,他的Role就是ROLE_SimulatedProxy。

4.1 服务器角色正常的移动流程

第三章节里面的图3-1就是单机或者ListenServer服务器执行的移动流程。作为一个本地控制的角色,他只需要认真的执行正常的移动(PerformMovement)逻辑处理即可,所以ListenServer服务器移动不再赘述。

但是对于DedicateServer,他的本地没有控制的角色,对移动的处理就有差异了。分为两种情况:

  1. 该角色在客户端是模拟(Simulate)角色,移动完全由服务器同步过去,如各类AI角色。这类移动一般是服务器上行为树主动触发的
  2. 该角色在客户端是拥有自治(Autonomous)权利的Character,如玩家控制的主角。这类移动一般是客户端接收玩家输入数据本地模拟后,再通过RPC发给服务器进行模拟的

从下面的代码可以了解到这两种情况的处理(注意注释):

// UCharacterMovementComponent:: TickComponent
// simulate的角色在服务器执行IsLocallyControlled也会返回true
// Allow root motion to move characters that have no controller.
if( CharacterOwner->IsLocallyControlled() || (!CharacterOwner->Controller && bRunPhysicsWithNoController) || (!CharacterOwner->Controller && CharacterOwner->IsPlayingRootMotion()) )
{
    {
        SCOPE_CYCLE_COUNTER(STAT_CharUpdateAcceleration);

        // We need to check the jump state before adjusting input acceleration, to minimize latency
        // and to make sure acceleration respects our potentially new falling state.
        CharacterOwner->CheckJumpInput(DeltaTime);

        // apply input to acceleration
        Acceleration = ScaleInputAcceleration(ConstrainInputAcceleration(InputVector));
        AnalogInputModifier = ComputeAnalogInputModifier();
    }

    if (CharacterOwner->Role == ROLE_Authority)
    {
        // 单机或者DedicateServer控制simulate角色移动
        PerformMovement(DeltaTime);
    }
    else if (bIsClient)
    {
        ReplicateMoveToServer(DeltaTime, Acceleration);
    }
}
else if (CharacterOwner->GetRemoteRole() == ROLE_AutonomousProxy)
{
    //DedicateServer控制自治客户端角色移动
    // Server ticking for remote client.
    // Between net updates from the client we need to update position if based on another object,
    // otherwise the object will move on intermediate frames and we won't follow it.
    MaybeUpdateBasedMovement(DeltaTime);
    MaybeSaveBaseLocation();

    // Smooth on listen server for local view of remote clients. We may receive updates at a rate different than our own tick rate.
    if (CharacterMovementCVars::NetEnableListenServerSmoothing && !bNetworkSmoothingComplete && IsNetMode(NM_ListenServer))
    {
        SmoothClientPosition(DeltaTime);
    }
}

这两种情况详细的流程我们在下面两个小结分析。

4.2 Autonomous角色

一个客户端的角色是完全通过服务器同步过来的,他身上的移动组件也一样是被同步过来的,所以游戏一开始客户端的角色与服务器的数据是完全相同的。对于Autonomous角色,大致的实现思路如下:

客户端通过接收玩家的Input输入,开始进行本地的移动模拟流程,移动前首先创建一个移动预测数据结构FNetworkPredictionData_Client_Character,执行PerformMovement移动,随后保存当前的移动数据(速度,旋转,时间戳以及移动结束后的位置等信息)到前面的FNetworkPredictionData里面的SavedMoves列表里面,并通过RPC将当前的Move数据发送该数据到服务器。然后继续进行TickComponent操作,重复这个流程。

客户端在发送给服务器RPC消息的同时,本地还会不断的执行移动模拟。SavedMoves列表里面的数据也就越来越多。如果这时候收到了一个ClientAckGoodMove调用,那么表示服务器接收了对应时间戳的客户端移动,客户端就将这个时间戳之前的SavedMoves全部移除。如果客户端收到了ClientAdjustPosition调用,那么表示对应这个时间戳的移动有问题,客户端需要修改成服务器传过来的位置,并重新播放那些还没被确认的SaveMoves列表里面的移动。
这里写图片描述
图4-1

整个流程如下图所示:
这里写图片描述
图4-2 Autonomous角色移动流程图

4.2.1 SavedMoves与移动合并

仔细阅读源码的朋友对上面给出的流程可能并不是很满意,因为除了ServerMove你可能还看到了ServerMoveDual以及ServerMoveOld等函数接口。而且除了SavedMoves列表,还有PendingMove,FreeMove这些移动列表。他们都是做什么的?

简单来讲,这属于移动带宽优化的一个方式,将没有意义的移动合并,减少消息的发送量。

当客户端执行完本次移动后,都会把当前的移动数据以一个结构体保存到SavedMove列表,然后会判断当前的这个移动是否可以被延迟发送(CanDelaySendingMove(),默认为true),如果可以就会继续判断当前的客户端网络速度如何。如果当前的速度有一点慢或者上次更新的时间很短,移动组件就会将当前的移动赋值给PendingMove(表示将要执行的移动)并取消本次给服务器消息的发送。

const bool bCanDelayMove = (CharacterMovementCVars::NetEnableMoveCombining != 0) && CanDelaySendingMove(NewMove);

if (bCanDelayMove && ClientData->PendingMove.IsValid() == false)
{
    // Decide whether to hold off on move
    // send moves more frequently in small games where server isn't likely to be saturated
    float NetMoveDelta;
    UPlayer* Player = (PC ? PC->Player : nullptr);
    AGameStateBase const* const GameState = GetWorld()->GetGameState();

    if (Player && (Player->CurrentNetSpeed > 10000) && (GameState != nullptr) && (GameState->PlayerArray.Num() <= 10))
    {
        NetMoveDelta = 0.011f;
    }
    else if (Player && CharacterOwner->GetWorldSettings()->GameNetworkManagerClass) 
    {
        //这里会根据网络管理的配置以及客户端网络速度来决定是否延迟发送
        NetMoveDelta = FMath::Max(0.0222f,2 * GetDefault<AGameNetworkManager>(CharacterOwner->GetWorldSettings()->GameNetworkManagerClass)->MoveRepSize/Player->CurrentNetSpeed);
    }
    else
    {
        NetMoveDelta = 0.011f;
    }

    if ((GetWorld()->TimeSeconds - ClientData->ClientUpdateTime) * CharacterOwner->GetWorldSettings()->GetEffectiveTimeDilation() < NetMoveDelta)
    {
        // Delay sending this move.
        ClientData->PendingMove = NewMove;
        return;
    }
}

当客户端进去下次Tick的时候,就会判断当前的新的移动是否能与上次保存的PendingMove合并。如果可以,就可以减少一次消息的发送。如果不能合并,那么在本次移动结束后给服务器发送一个两次移动(ServerMoveDual),就是单纯的执行两次ServerMove。

服务器在受到两次移动的时候对第一次移动不进行任何校验,只对第二个移动进行正常的校验,判断是否是第一次的标准就是ClientPosition是不是FVector(1.f,2.f,3.f)。通过下面的代码就可以了解了

void UCharacterMovementComponent::ServerMoveDual_Implementation(
    float TimeStamp0,
    FVector_NetQuantize10 InAccel0,
    uint8 PendingFlags,
    uint32 View0,
    float TimeStamp,
    FVector_NetQuantize10 InAccel,
    FVector_NetQuantize100 ClientLoc,
    uint8 NewFlags,
    uint8 ClientRoll,
    uint32 View,
    UPrimitiveComponent* ClientMovementBase,
    FName ClientBaseBone,
    uint8 ClientMovementMode)
{
    ServerMove_Implementation(TimeStamp0, InAccel0, FVector(1.f,2.f,3.f), PendingFlags, ClientRoll, View0, ClientMovementBase, ClientBaseBone, ClientMovementMode);
    ServerMove_Implementation(TimeStamp, InAccel, ClientLoc, NewFlags, ClientRoll, View, ClientMovementBase, ClientBaseBone, ClientMovementMode);
}

其实,UE的思想就是,将所有的移动的关键信息都数据化,这样移动就可以自由的存储和回放。为了节省带宽,提高效率,我们也就可以想出各种办法来减少发送不必要的消息,对于一个没有移动过的玩家,理论上我们甚至都可以不去同步他的移动信息。
这里写图片描述
图4-3 移动预测及保存的数据结构示意图

4.3 Simulate角色

首先看一下官方文档对Simulate角色移动的描述:

对于那些不由人类控制的人物,其动作往往会通过正常的 PerformMovement() 代码在服务器(此时充当了主控者)上进行更新。Actor 的状态,如方位、旋转、速率和其他一些选定的人物特有状态(如跳跃)都会通过正常的复制机制复制到其他机器,因此,它们不必在每一帧都经由网络传送。为了在远程客户端上针对这些人物提供更流畅的视觉呈现,该客户端机器将在每一帧为模拟代理执行一次模拟更新,直到新的数据(由服务器主控)到来。本地客户端查看其他远程人类玩家时也是如此;远程玩家将其更新发送给服务器,后者为该玩家执行一次完整的动作更新,然后定期复制数据给所有其他玩家。
这个更新的作用是根据复制的状态来模拟预期的动作结果,以便在下一次更新前“填补空缺”。所以,客户端并没有在新的位置放置由服务器发送的代理,然后将它们保留到下次更新到来(可能是几个后续帧),而是通过应用速率和移动规则,在每一帧模拟出一次更新。在另一次更新到来时,客户端将重置本地模拟并开始新一次模拟。

简单来说,Simulate角色的在服务器上的移动就是正常的PerformMovement流程。而在客户端上,该角色的移动分成两个步骤来处理——收到服务器的同步数据时就直接进行设置。在没有收到服务器消息的时候根据上一次服务器传过来的数据(包括速度与旋转等)在本地执行Simulate模拟,等着下一个同步数据到来。Simulate角色采用这样的机制,本质上是为了减小同步带来的开销。下面代码展示了所有Character的同步属性

    void ACharacter::GetLifetimeReplicatedProps( TArray< FLifetimeProperty > & OutLifetimeProps ) const
    {
        Super::GetLifetimeReplicatedProps( OutLifetimeProps );
        DOREPLIFETIME_CONDITION( ACharacter, RepRootMotion,COND_SimulatedOnlyNoReplay);
        DOREPLIFETIME_CONDITION( ACharacter, ReplicatedBasedMovement,   COND_SimulatedOnly );
        DOREPLIFETIME_CONDITION( ACharacter, ReplicatedServerLastTransformUpdateTimeStamp, COND_SimulatedOnlyNoReplay);

        DOREPLIFETIME_CONDITION( ACharacter, ReplicatedMovementMode,    COND_SimulatedOnly );
        DOREPLIFETIME_CONDITION( ACharacter, bIsCrouched,           COND_SimulatedOnly );

        // Change the condition of the replicated movement property to not replicate in replays since we handle this specifically via saving this out in external replay data
        DOREPLIFETIME_CHANGE_CONDITION(AActor,ReplicatedMovement,COND_SimulatedOrPhysicsNoReplay);


    }

ReplicatedMovement记录了当前Character的位置旋转,速度等重要的移动数据,这个成员(包括其他属性)在Simulate或者开启物理模拟的客户端才执行(可以先忽略NoReplay,这个和回放功能有关)。同时,我们可以看到Character大部分的同步属性都是与移动同步有关,而且基本都是SimulatedOnly,这表示这些属性只在模拟客户端才会进行同步。除了ReplicatedMovement属性以外,ReplicatedMovementMode同步了当前的移动模式,ReplicatedBasedMovement同步了角色所站在的Component的相关数据,ReplicatedServerLastTransformUpdateTimeStamp同步了最新的服务器移动更新帧,也就相当于最后一次服务器更新移动的时间(在ACharacter::PreReplication里会将服务器当前的移动数据赋值给ReplicatedServerLastTransformUpdateTimeStamp然后进行同步)。

了解了这些同步的数据后,我们开始分析其移动流程。流程如下图所示(RootMotion的情况我在上一章节已经描述,这里不再赘述)。其实其基本思路与普通的移动处理相似,只不过是调用SimulateTick去根据当前的速度等条件模拟客户端移动,但是有一点非常重要的差异就是Simulate的角色的胶囊体移动与Mesh移动是分开进行的。这么做的原因是什么呢?我们稍后再解释。
这里写图片描述
图4-4 Simulate角色移动流程图

客户端的模拟我们大致了解了流程,那么接收服务器数据并修正是在哪里处理的呢?答案是AActor::OnRep_ReplicatedMovement。客户端在接收到服务器同步的ReplicatedMovement时,会产生回调函数触发SmoothCorrection的执行,从当前客户端的位置平滑的过度到服务器同步的位置。

前面提到了胶囊体与Mesh的移动是分开处理的,其目的就是提高代理模拟的流畅度。其实在官方文档上有简单的例子,

比如这种情况,一个 replicated 的状态显示当前的角色在时间为 t=0 的时刻以速度 (100, 0, 0) 移动,那么当时间更新到 t=1 的时候,这个模拟的代理将会在 X 方向移动 100 个单位,然后如果这时候服务端的角色在发送了那个 (100, 0, 0) 的 replcated 信息后立刻不动了,那么这个 replcated 信息则会使到服务端角色的位置和客户端的模拟位置处于不同的点上。

为了避免这种“突变”情况,UE采用了Mesh网格的平滑操作。胶囊体的移动正常进行,但是其对应的Mesh网格不随胶囊体移动,而要通过SmoothClientPosition处理,在SmoothNetUpdateTime时间内完成移动,这样玩家在视觉上就不会觉得代理角色的位置突变。通过FScopedPreventAttachedComponentMove类可以限制某个组件暂时不跟随父类组件移动。

对于Smooth平滑,UE定义了下面几种情况,默认我们采用Exponential(指数增长,越远移动越快):

    /** Smoothing approach used by network interpolation for Characters. */
    UENUM(BlueprintType)

     enum class ENetworkSmoothingMode : uint8
     {
       /** No smoothing, only change position as network position updates are received. */
       Disabled     UMETA(DisplayName="Disabled"),

       /** Linear interpolation from source to target. */
       Linear           UMETA(DisplayName="Linear"),

       /** Exponential. Faster as you are further from target. */
       Exponential      UMETA(DisplayName="Exponential"),

       /** Special linear interpolation designed specifically for replays. Not intended as a selectable mode in-editor. */
       Replay           UMETA(Hidden, DisplayName="Replay"),
     };

4.4 关于物理托管后的移动

一般情况下我们是通过移动组件来控制角色的移动,不过如果给玩家角色的胶囊体(一般Mesh也是)勾选了SimulatePhysics,那么角色就会进入物理托管而不受移动组件影响,组件的同步自然也是无效了,常见的应用就是玩家结合布娃娃系统,角色死亡后表现比较自然的摔倒效果。相关代码如下:

// // UCharacterMovementComponent::TickComponent
// We don't update if simulating physics (eg ragdolls).
if (bIsSimulatingPhysics)
{
    // Update camera to ensure client gets updates even when physics move him far away from point where simulation started
    if (CharacterOwner->Role == ROLE_AutonomousProxy && IsNetMode(NM_Client))
    {
        APlayerController* PC = Cast<APlayerController>(CharacterOwner->GetController());
        APlayerCameraManager* PlayerCameraManager = (PC ? PC->PlayerCameraManager : NULL);
        if (PlayerCameraManager != NULL && PlayerCameraManager->bUseClientSideCameraUpdates)
        {
            PlayerCameraManager->bShouldSendClientSideCameraUpdate = true;
        }
    }
    return;
}

对于开启物理的Character,Simulate的客户端也是采取移动数据靠服务器同步的机制,只不过移动的数据不是服务器PerformMovement算出来的,而是从根组件的物理对象BodyInstance获取的,代码如下。

void AActor::GatherCurrentMovement()
{
    AttachmentReplication.AttachParent = nullptr;

    UPrimitiveComponent* RootPrimComp = Cast<UPrimitiveComponent>(GetRootComponent());
    if (RootPrimComp && RootPrimComp->IsSimulatingPhysics())
    {
        FRigidBodyState RBState;
        RootPrimComp->GetRigidBodyState(RBState);

        ReplicatedMovement.FillFrom(RBState, this);
        ReplicatedMovement.bRepPhysics = true;
    }
}

原文链接(转载请标明):http://blog.csdn.net/u012999985/article/details/78669947

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/u012999985/article/details/78669947

智能推荐

springboot解析自定义yml_spring boot自定义yml键-程序员宅基地

文章浏览阅读850次。springboot解析自定义yml在实际项目开发中我们经常需要用到一些自定义配置,并且希望单独配置,方便维护,现在介绍下方式:方式一手动加载对于一些不变动的配置,写死在项目中维护,如下然后在启动类中加载该配置‘’’public class AAApplication { public static void main(String[] args) { Spr..._spring boot自定义yml键

Effective C++学习笔记_effective c++ 学习笔记-程序员宅基地

文章浏览阅读503次。Effective C++学习笔记1.让自己习惯使用C++条款01:视c++为一个语言联邦条款02:尽量以const,enum,inline来替换#define功能快捷键合理的创建标题,有助于目录的生成如何改变文本的样式插入链接与图片如何插入一段漂亮的代码片生成一个适合你的列表创建一个表格设定内容居中、居左、居右SmartyPants创建一个自定义列表如何创建一个注脚注释也是必不可少的KaTeX数..._effective c++ 学习笔记

对qps、tps、rt、并发数、吞吐量、限流、熔断和降级的了解_rt 限流-程序员宅基地

文章浏览阅读1.3k次。在10.24晚上8点时双十一大促预售,看到流量监控的大屏上总会出现QPS、RT的信息,并且在某些服务调用超时或时间较长时,总会听到师兄说对他进行一个降级,所以这里进行一个归纳。在系统访问量激增,大量请求涌进来时,如果调用的某些第三方接口老是超时或是失败又或者是很慢,_rt 限流

android六大布局和UI组件_android:layout_row-程序员宅基地

文章浏览阅读8.9k次,点赞11次,收藏64次。一:android之六大布局1、在4.0以前版本中一共有五种布局,都是ViewGroup的子类。分别是AbsoluteLayout、RelativeLayout、LinearLayout、(FrameLayout)、TableLayout。而TableLayout是LinearLayout的子类。(中文分别是:绝对布局、相对布局、线性布局、帧布局、表格布局)。2、在2.2操作系_android:layout_row

工作周报_工作周报关键问题/风险-程序员宅基地

文章浏览阅读5.6k次,点赞2次,收藏7次。5月28日—6月1日本周工作内容:1, 完成新接口获取设备状态的验证联调 2, 明确TalkClinet插件事件上报和回调机制的实现 3, 解决插件崩溃问题的定位和排查[dump文件] 4, 定位TTS GetOutputStream在服务器上运行时失败的问题 下周工作计划:1, 完成设备状态转换代码开发和联调 2, 熟悉监舍点名功能项目的需求、整体架..._工作周报关键问题/风险

C编程实现转换字符串、插入字符的个数_void string_trans(char *str)-程序员宅基地

文章浏览阅读88次。#include <stdlib.h>#include <stdio.h>#include <string>char* transfromion(char* str){ int len = strlen(str); char* buffer = new char[len + 1]; char* p = str; char* q = p + 1..._void string_trans(char *str)

随便推点

什么是中台(个人理解)-程序员宅基地

文章浏览阅读7.4w次,点赞8次,收藏21次。中台是一套结合互联网技术和行业特性,将企业核心能力以共享服务中心进行沉淀,形成“大中台、小前台”的组织和业务机制,供企业快速低成本的进行业务创新的企业架构;最近看到了好多回复,感觉到了大家对中台的兴趣,这里再多说两句吧1、中台提倡的是大中台小前台的模式,简单点说就是核心+共用的逻辑全部可以抽象在中台中实现,前台(例如售卖、展示等)由于渠道众多,因此这一块越小就越灵活,上线及变更时间就越短。...

PyTorch模型训练的几个加速技巧-程序员宅基地

文章浏览阅读2.4k次,点赞2次,收藏5次。这里尝试一下网上找到pyorch模型训练加速技巧,尝试一下,看看加速的效果,然后尽可能分析一下加速在哪个地方。1.有时我们会发现,使用Pytorch的模型,训着训着,越来慢了或者outofmemory,这时我们该怎么解决呢? 主要是因为,模型可能在不断的产生临时变量,这个时候,我们可以设置程序每隔多少次进行一次清除操作:torch.cuda.empty_cache()2.在数据读取速度不变的情况下,怎样进一步提高模型的训练速度呢? PyTorch 会默认使用 cuDNN 加速,并且c

系统进入登陆界面就重启-程序员宅基地

文章浏览阅读781次。在启动界面就蓝屏,最有可能的就是WINLOGON进程的原因,可能在进程里插入了一些未知线程,例如病毒等,最好的方法是重装,系统很可能染毒了,最近MPEG-2的0day漏洞很猖狂的,还不好防,趁现在还能进系统,把有用的文件拷出来,不然等病毒破坏了安全模式,再搞可就要麻烦点了 回答者: woiask - 经理 四级 2009-7-7 17:15应该是系统文件丢失了,突然..._windows登陆后自动重启

树莓派安装linux-程序员宅基地

文章浏览阅读2.1k次,点赞2次,收藏6次。1、ubuntu官方操作 参考:https://ubuntu.com/tutorials/how-to-install-ubuntu-desktop-on-raspberry-pi-4#1-overview2、一般操作_树莓派安装linux

nodejs ssh2 shell函数发送命令不成功的问题_nodejs ssh2 no response from server-程序员宅基地

文章浏览阅读2.9k次。在使用nodejs ssh2的shell函数时,我们用conn.write和conn.end发送命令,经常会遇到命令发送不过去的情况,我自己也遇到这种情况,纠结了很久,最后把write,end发送命令的代码,放在接收到第一个data事件之后去发送,就不会出现这种发不过去的情况了。后来咨询了一下写nodejs ssh2代码的作者,才知道当建立stream连接后,数据的缓冲区可能没有准备好,如果此时对缓_nodejs ssh2 no response from server

C语言:对象继承之多继承_c语言面向对象多继承-程序员宅基地

文章浏览阅读1.1k次。继承除了public继承,还包括private继承和多继承。把派生类中的父类成员改成指针形式的前向声明即可实现private继承。多继承即派生类会有一个以上的基类,多继承实现和单继承本质上无太大差异,但是最关键的是父类和派生子类相互转换的偏移量,这是虚函数和数据成员导致的,采用类似MFC消息映射的原理定义多继承的子类和父类的关系映射最终实现子类和父类的相关转换。_c语言面向对象多继承

推荐文章

热门文章

相关标签