UE4移动组件详解(二)——移动同步机制_Jerish_C的博客-程序员秘密_ue4 basedmovement 同步

技术标签: 同步机制  移动  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

智能推荐

ubuntu如何查看网卡名称_【Ubuntu】16.04网卡信息配置_weixin_39970064的博客-程序员秘密

①查看操作系统版本信息:②查看网卡信息输入ifconfig命令查看网卡信息,下图enp0s25就是网卡名称。ifconfig③配置网卡文件注意:不同的linux系统,网卡配置文件是不同的,这里ubuntu的网卡配置文件是/etc/network/interfaces。输入下面命令进行编辑网卡文件sudo vi /etc/network/interfaces默认的文件内容如下:#interfaces...

com.alibaba.druid.sql.parser.ParserException问题_Maruko310的博客-程序员秘密_sql.parser.parserexception

最近开发碰到一个问题,报错显示的错误是com.alibaba.druid.sql.parser.ParserException,我们开发外网数据库用的是 PostgreSQL,内网数据库用的是hawq。外网是没有任何问题的,上内网测试部分SQL就会报这个错误。在网上百度了,有几种解决办法:第一种说法:数据库版本与Druid 冲突,更换Druid 版本为1.1.10以后的版本,但是并没有解决...

HTML期末大作业~ 明星林依晨(7页)带留言 学生网页设计作业源码(HTML+CSS+JS)[email protected]码住夏天-web网页设计的博客-程序员秘密

HTML期末大作业~ 明星林依晨(7页)带留言 学生网页设计作业源码(HTML+CSS+JS)临近期末, 你还在为HTML网页设计结课作业,老师的作业要求感到头大?HTML网页作业无从下手?网页要求的总数量太多?没有合适的模板?等等一系列问题。你想要解决的问题,在这篇博文中基本都能满足你的需求~原始HTML+CSS+...

vue3.0需要学习的技术栈_尔嵘的博客-程序员秘密_vue3技术栈

相关库名称在线地址 ????Vue 3.0 官方文档(英文)在线地址Vue 3.0 中文文档在线地址 国内加速版Composition-API手册在线地址Vue 3.0 源码学习在线地址Vue-Router 官方文档在线地址Vuex 4.0Githubvue-devtoolsGithub(Vue3.0 需要使用最新版本)Vite 源码学习线上地址Vite 2.0 中文文档线上地址Vue3 新动态线上地址一、常规学习技术:vue3.0官方文档:二、Vue 3.0 搭配UI库:1.Eleme

layui 加载第三方插件_优小U的博客-程序员秘密_layui第三方组件

layui 加载第三方插件1. 使用模块2. 内置模块3. 扩展一个 layui 模块4. 加载第三方插件为layui模块1. 使用模块Layui的模块加载采用核心的 layui.use(mods, callback)方法,当你的JS 需要用到Layui模块的时候,我们更推荐你采用预先加载,因为这样可以避免到处写layui.use的麻烦。你应该在最外层如此定义:layui.use(['form', 'upload'], function(){ //如果只加载一个模块,可以不填数组。如:layui.u

LeetCode题解_阳光的碎屑的博客-程序员秘密_leetcode题解

剑指Offer03. 数组中重复的数字题目描述:找出数组当中重复的数字解决思路:用集合保存出现过的数字,利用集合当中的数据是非重复的判断重复出现的数字。Java题解class Solution { public int findRepeatNumber(int[] nums) { Set&lt;Integer&gt; set = new HashSet&lt;&gt;(); int repeat = -1; for (int num :

随便推点

hbase region合并文件制作脚本_咖啡F的博客-程序员秘密

脚本如下#!/bin/bashFile_split=400Master_ip=116。…time=$(date “+%Y_%m_%d”)Region_Name=1RegionNameFile="1Region_Name_File="1RegionN​ameF​ile="Region_Name"_"$time"if [ ! -n “$1” ];thenecho “Plase input your region!”elsecurl http://Masterip:60010/table.js

硬件设计38之什么是MCASP?_wangdapao12138的博客-程序员秘密

1.McASPMcASP是美国TI公司的DSP的一种接入接口。称为复通道音频接入接口。这是一种通用的音频接入接口。采用的是时分复用的数据流形式。https://wenku.baidu.com/view/ccc1e9fdba0d4a7302763a3d.html。McASP即multi-channel audio serial port,是通用音频接口,支持TDM(Time-Division Mul...

详细讲解架设FTP服务器的两种方法_duxikuan的博客-程序员秘密

摘要:架设一台FTP服务器其实很简单。首先,要保证你的机器能上网,而且有不低于ADSL 512Kbps的网络速度。其次,硬件性能要能满足你的需要。最后,需要安装FTP服务器端架设一台FTP服务器其实很简单。首先,要保证你的机器能上网,而且有不低于ADSL 512Kbps的网络速度。其次,硬件性能要能满足你的需要。最后,需要安装FTP服务器端的软件,这类软件很多,可以使用微软的 IIS(In

Lumen企业站内容管理实战 - 网站配置_wangpeng52758的博客-程序员秘密_lumen 站内信

网站配置,就是网站的一些基本信息,话不多说,上图为了演示,我写了这些信息,其实应该还有其他配置的。这些信息是要保存的,但是数据库中缺少表,我们需要建立一张数据表,表名叫settings,我准备使用数据库迁移方式建表,这个很方便,很好用!之前我们建立了一个数据库,叫cms-test,并且在.env中配置好了。现在打开你的命令行工具,切换到项目目录下,执行命令php a...

关于QT编码格式问题导致的异常报错(涉及中文字符问题)_系统更新中的博客-程序员秘密_qt 文件重新打开之后中检测到utf-16(le)字节顺序标记,但不支持编码

关于QT编码格式导致的异常报错问题问题1:“ error: C2001: 常量中有换行符”,因为中文字符导致的报错问题2:因为“/* */”注释导致的异常报错原因:涉及中文字符的,编译器要求支持utf-8带BOM的方式,对UTF8格式的代码文件支持不够好解决办法:1、首先设置编码格式,默认就是UTF-82、通过notepad++文本编辑器把源代码文件编码格式为UTF-8 BOM保存即可...

一个奇怪的数组越界报错_Jamin_Ye的博客-程序员秘密_props beans 会报数组越界

一个奇怪的数组越界报错遇到一个很奇怪的报错,报错详情见下方翻译一下就是不能读取ReportExportThread 这个组件奇怪的点就来了,这个类我并没有让spring管理org.springframework.beans.factory.BeanDefinitionStoreException: Failed to read candidate component class: file [/xx/xx/xx/xx/xxx/util/ReportExportThread.class]; neste

推荐文章

热门文章

相关标签