鸿蒙NEXT(五):鸿蒙版React Native架构浅析

鸿蒙版React Native架构

如图,React Native for OpenHarmony 在 React Native 的新架构(0.68以及之后的版本)的基础上,进行了鸿蒙化的适配。按照功能可以进行如下的划分:

  • RN 应用代码:开发者实现的业务代码。
  • RN 库代码:在 React Native 供开发者使用的组件和API的封装与声明。
  • JSI(JavaScript Interface):JavaScript 与 CPP 之间进行通信的API。
  • React Common:所有平台通用的 CPP 代码,用于对 RN 侧传过来的数据进行预处理。
  • OpenHarmony 适配代码:接收并处理 React Common 传过来的数据,对接原生的代码,调用 ArkUI 的原生组件与 API。主要包括了两个部分:分别是 TurboModule 与 Fabric。
  • OS代码:对接系统底层功能,根据适配层代码传过来的数据进行渲染,或完成对应的功能。
React Native库代码

在现行的 React Native 中,有很多属性是在React侧完成的封装,也有很多属性是平台独有的。为了达成这个效果,React Native 在JS侧根据Platform增加了很多判断。所以,React Native 的鸿蒙化适配也需要增加HarmonyOS相关的平台判断,与相应的组件属性的封装。为此,鸿蒙化团队提供了react-native-harmony的tgz包,并通过更改metro.config.js配置,将该tgz包应用到 Metro Bundler中。

React Native 还提供了很多库的封装,例如Codegen、打包工具等。为此,鸿蒙化团队提供了react-native-harmony-cli的包,对这些库进行了HarmonyOS平台的适配,用于向开发者提供相关的功能。

Fabric

Fabric 是 React Native 的组件渲染系统。接收 React Native 传过来的组件信息,处理后发送给原生OS,由OS完成页面的渲染。

在适配方案中,组件不通过复杂的流程对接到ArkUI的声明式范式上,而是直接使用XComponent对接到ArkUI的后端接口进行渲染,缩短了流程,提高了组件渲染的效率。C-API的性能收益包括以下的几个部分:

  • C端最小化、无跨语言的组件创建和属性设置;
  • 无跨语言前的数据格式转换,不需要将string,enum等数据类型转换为object,可以在CPP侧直接使用对应的数据进行处理;
  • 可以进行属性Diff,避免重复设置,降低了属性设置的开销。

渲染流水线请参考渲染三阶段

TurboModule

TurboModule 是 React Native 中用于 JavaScript 和原生代码进行交互的模块,为RN JS应用提供调用系统能力的机制。根据是否依赖 HarmonyOS系统相关的能力,可以分为两类:cxxTurboModule和ArkTSTurboModule。

  1. ArkTSTurboModule:
  • ArkTSTurboModule为 React Native 提供了调用ArkTS原生API的方法。可以分为同步与异步两种。
  • ArkTSTurboModule依赖NAPI进行原生代码与CPP侧的通信。包括JS与C之间的类型转换,同步和异步调用的实现等。
  1. cxxTurboModule:
  • cxxTurboModule主要提供的是不需要系统参与的能力,例如NativeAnimatedTurboModule主要提供了数据计算的相关能力。
  • cxxTurboModule不依赖于系统的原生API,为了提高相互通信的效率,一般是在cpp侧实现,这样可以减少native与cpp之间的通信次数,提高性能。
React Native线程模型
RNOH线程模型
RNOH的线程一共有3个:
enum TaskThread {
  MAIN = 0, // main thread running the eTS event loop
  JS, // React Native's JS runtime thread
  BACKGROUND, // background tasks queue
};
MAIN/UI线程

RN业务主线程,也是应用主线,应用UI线程。该线程在应用中有唯一实例。

RN在MAIN线程中主要承担的业务功能是:

  • ArkUI组件的生命周期管理:CREATE, UPDATE, INSERT, REMOVE, DELETE;
  • ArkUI组件树管理;
  • RN TurboModule业务功能运行;
  • 交互事件、消息处理。
JS线程

JS线程通过虚拟机执行React(JS)代码,通过React代码与RN Common的核心代码交互完成React Native的Render阶段任务。

RN在JS线程中主要承担的业务功能是:

  • 加载Bundle,执行Bundle依赖的React代码和Bundle的业务代码。
  • 由React业务代码驱动,创建RN ShadowTree,设置ShadowTree的属性。
  • 利用Yoga引擎进行组件布局,文本测量和布局。
  • 比较完成布局的新、老ShadowTree,生成差异结果mutations。将mutations提交到MAIN线程触发Native的显示刷新。
  • 交互事件、消息处理。

JS线程与RNInstance的实例绑定,有多个RNInstance,则有多个对应的JS线程。

BACKGROUND线程

BACKGROUND线程是RN的实验特性,开启BACKGROUND线程后,会将JS线程的部分布局、ShadowTree比较的任务迁移到该线程执行,从而降低JS线程的负荷。

由于开启BACKGROUND涉及复杂的线程间通信,在稳定性方面带来风险,因此正式商用版本中不要开启BACKGROUND线程。

RNOH线程的长期演进

MAIN线程和JS线程承担了RN框架的全部业务,在重载情况下可能会造成性能瓶颈。RN的业务也受同线程的其他应用代码的影响,造成执行延迟或阻塞等问题。

在长期演进时,可以考虑进行线程扩展:

  • 增加唯一TM线程,将TurboModule的业务代码放到TM线程来执行,从而降低MAIN线程负荷。
  • 增加单独的TIMER线程,确保时间基准稳定执行。
典型线程Trace图
  • 线程号53130:MAIN线程
  • 线程号53214:JS线程实例1
  • 线程号53216:JS线程实例2
命令式组件
XComponent接入

CAPI 版本使用XComponent总共分成了两个步骤:

  1. createSurface的时候创建XComponentSurface;
  2. startSurface的时候将CPP的XComponentSurface连接到ArkUI的Xcomponent上。

createSurface的时候主要做了以下的操作:

  1. 创建并将XComponentSurface记录到Map中:

    void RNInstanceCAPI::createSurface(

    facebook::react::Tag surfaceId,

    std::string const& moduleName) {

    m_surfaceById.emplace(

    surfaceId,

    XComponentSurface(

    ···

    surfaceId,

    moduleName));

    }

  2. 在XComponentSurface中创建rootView,用于挂载C-API的组件,并在Surface上统一处理Touch事件:

    XComponentSurface::XComponentSurface(

    ···

    SurfaceId surfaceId,

    std::string const& appKey)

    :

    ···

    m_nativeXComponent(nullptr),

    m_rootView(nullptr),

    m_surfaceHandler(SurfaceHandler(appKey, surfaceId)) {

    m_scheduler->registerSurface(m_surfaceHandler);

    m_rootView = componentInstanceFactory->create(

    surfaceId, facebook::react::RootShadowNode::Handle(), "RootView");

    m_componentInstanceRegistry->insert(m_rootView);

    m_touchEventHandler = std::make_unique(m_rootView);

    }

startSurface的时候主要做了以下的操作:

  1. 在ArkTS侧创建XComponent,并设置id,type与libraryname属性。其中:
  • id:组件的唯一标识,又由InstanceID和SurfaceID共同组成,记录了此XComponent属于哪一个Instance与Surface;

  • type:node,标识该XComponent是一个占位组件,组件的实现都在CAPI侧;

  • libraryname:表示C-API组件在哪个so库中实现,并加载该so库,自动调用该so中定义的Init函数。当前React Native for OpenHarmony默认的so名字为rnoh_app。

    XComponent({

    id: this.ctx.rnInstance.getId() + "_" + this.surfaceHandle.getTag(),

    type: "node",

    libraryname: 'rnoh_app'

    })

  1. 在CPP侧的Init中调用registerNativeXComponent函数,该函数中调用了OH_NativeXComponent_GetXComponentId用于获取ArkTS设置的id,并根据id找到对应的Instance与Surface。同时还要获取nativeXComponent对象,记录ArkTS侧的XComponent。

    if (OH_NativeXComponent_GetXComponentId(nativeXComponent, idStr, &idSize) !=

    OH_NATIVEXCOMPONENT_RESULT_SUCCESS) {

    ···

    }

    std::string xcomponentStr(idStr);

    std::stringstream ss(xcomponentStr);

    std::string instanceId;

    std::getline(ss, instanceId, '');
    std::string surfaceId;
    std::getline(ss, surfaceId, '
    ');

  2. 调用OH_NativeXComponent_AttachNativeRootNode,将XComponentSurface中记录的rootView连接到ArkTS侧的XComponent上:

    OH_NativeXComponent_AttachNativeRootNode(

    nativeXComponent,

    rootView.getLocalRootArkUINode().getArkUINodeHandle());

  3. 将rootView连接到XComponent后,rootView就作为CAPI组件的根节点,后续的子孙节点通过Mutation指令逐个插入到组件树上。

CAPI组件向上对接RN指令
  1. 在RN鸿蒙适配层中,SchedulerDelegate.cpp负责处理RN Common传递下来的指令。

    void SchedulerDelegate::schedulerDidFinishTransaction(MountingCoordinator::Shared mountingCoordinator) {

    ...

    }

  2. 在 MountingManagerCAPI.cpp 的didMount中对各个指令进行处理。

    MountingManagerCAPI::didMount(MutationList const& mutations) {

    ...

    }

在didMount函数中,先根据预先配置的arkTsComponentNames获取ArkTs组件和CAPI组件的指令,分别进行处理。其中CAPI组件的指令会在handleMutation方法中逐个遍历每个指令,根据指令的类型(Create 、Delete、Insert、Remove、Update)进行不同的处理。

  • Create指令:接收到Create指令后,会根据指令的tag、componentName和componentHandle信息创建出一个对应组件类型的ComponentInstance,比如Image组件的Create指令,会创建对应的ImageComponentInstance。创建完组件之后,调用updateComponentWithShadowView方法设置组件的信息。其中,setLayout设置组件的布局信息,setEventEmitter设置组件的事件发送器,setState设置组件的状态,setProps设置组件的属性信息。
  • Delete指令:根据接收到的Delete指令的tag,删除对应组件的ComponentInstance。
  • Insert指令:根据接收到Insert指令中包含父节点的tag和子节点的tag,将子节点插入到对应的父节点上。
  • Remove指令:接收到Remove指令中包含父节点的tag和子节点的tag,在父节点上移除对应的子节点。
  • Update指令:接收到Update指令后,调用组件的setLayout、setEventEmitter、setState、setProps更新组件相关信息。
适配层事件分发逻辑
1.适配层事件的注册

当手势触碰屏幕后会命中相应的结点,通过回调发送对应事件,但是需要注册事件,如一个Stack节点注册了NODE_ON_CLICK事件。

StackNode::StackNode()
:ArkUINode(NativeNodeAPi::getInstance()->createNode(ArkUI_NodeType::ARKUI_NODE_STACK)),
    m_stackNodeDelegate(nullptr)
    {
        maybeThrow(NativeNodeApi::getInstance()->registerNodeEvent(m_nodeHandle,NODE_ON_CLICK,0,this));
        maybeThrow(NativeNodeApi::getInstance()->registerNodeEvent(m_nodeHandle,NODE_ON_HOVER,0,this));
    }

SurfaceTouchEventHandler注册了NODE_TOUCH_EVENT事件。

SurfaceTouchEventHandler(
    ComponentInstance::Shared rootView,
    ArkTSMessageHub::Shared arkTSMessageHub,int rnInstanceId):
    ArkTSMessageHub::Observer(arkTSMessageHub),
    m_rootView(std::move(rootView)),
    m_rnInstanceId(rnInstanceId)
    {
        ArkUINodeRegistry::getInstance().registerTouchHandler(
            &m_rootView->getLocalRootArkUINode(),this);
            NativeNodeApi::getInstance()->registerNodeEvent(
                m_rootView->getLocalRootArkUINode().getArkUINodeHandle(),
                NODE_TOUCH_EVENT,
                NODE_TOUCH_EVENT,
                this);
    }
2.适配层事件的接收

ArkUINodeRegistry的构造中注册了一个回调,当注册了事件的节点被命中后,该事件通过回调传递处理。

ArkUINodeRegistry::ArkUINodeRegistry(ArkTSBridge::Shared arkTSBridge):m_arkTSBridge(std::move(arkTSBridge))
{
    NativeNodeApi::getInstance()->registerNodeEventReceiver(
        [](ArkUI_NodeEvent* event){
            ArkUINodeRegistry::getInstance().receiveEvent(event);
            });
}
3.适配层事件的处理

回调传递的参数event通过OH_ArkUI_NodeEvent_GetEventType获取事件类型,通过OH_ArkUI_NodeEvent_GetNodeHandle获取触发该事件的结点指针。

auto eventType = OHArkUI_NodeEvent_GetEventType(event);
auto node = OH_ArkUI_NodeEvent_GetNodeHandle(event);

首先判断事件类型是否为Touch事件,如果是,就从一个存储了所有TouchEventHandler的Map中通过结点指针作为key去查找对应的TouchEventHandler,如果没找到,这次Touch事件不处理。

if(eventType == ArkUI_NodeEventType::NODE_TOUCH_EVENT)
{
    auto it = m_touchHandlerByNodeHandle.find(node);
    if(it == m_touchHandlerByNodeHandle.end())
    {
        return;
    }
}

如果找到了对应的TouchEventHandler,通过OH_ArkUI_NodeEvent_GetInputEvent获取输入事件指针,若输入事件指针不为空,通过OH_ArkUI_UIInputEvent_GetType判断输入事件指针的类型是否为Touch事件,如果不是,这次Touch事件不处理。

auto inputEvent = OH_ArkUI_NodeEvent_GetInputEvent(event);
if(inputEvent == nullptr || OH_ArkUI_UIInputEvent_GetType(inputEvent) != ArkUI_UIInputEvent_Type::ARKUI_UIINPUTEVENT_TYPE_TOUCH)
{
    return;
}

如果上述两个条件都满足,就通过TouchEventHandler去处理Touch事件。

it->second->onTouchEvent(inputEvent);

如果事件类型不为Touch事件,就从一个存储了所有ArkUINode的Map中通结点指针作为key去查找对应的ArkUINode,若未找到,这次事件不处理。

auto it = m_nodeByHandle.find(node);
if(it == m_nodeByHandle.end())
{
    return;
}

如果找了对应的ArkUINode,通过OH_ArkUI_NodeEvent_GetNodeComponentEvent获取组件事件指针,该指针的data字段保留了arkUI传递过来的参数,并通过ArkUINode处理该事件。

auto commponentEvent = OH_ArkUI_NodeEvent_GetNodeComponentEvent(event);
if(commponentEvent != nullptr)
{
    it->second->onNodeEvent(eventType,compenentEvent->data);
    return;
}
4.Touch事件的传递给JS侧

上文中写明TouchEventHandler对Touch事件进行处理,以xcomponentSurface举例,xcomponentSurface有一个继承了TouchEventHandler的成员变量,这个成员变量通过dispatchTouchEvent处理这次Touch事件。

void onTouchEvent(ArkUI_UIInputEvent* event)override
{
    m_touchEventDispatcher.dispatchTouchEvent(event,m_rootView);
}

对于Touch事件首先通过Touch的位置等因素,获取对应touchTarget(每个componentInstance就是一个touchTarget,下图的名字是eventTarget)。

class ComponentInstance:public TouchTarget,public std::enable_shared_from_this<ComponentInstance>
for(auto const& targetTouches:touchByTargetId)
{
    auto it = m_touchTargetByTouchId.find(targetTouches.second.begin()->identifier);
    if(it == m_touchTargetByTouchId.end())
    {
        continue;
    }
    auto eventTarget = it->second.lock();
    if(eventTarget == nullptr)
    {
        m_touchTargetByTouchId.erase(it);
        continue;
    }
}

然后通过componentInstance保存的m_eventEmitter发送对应的事件给js侧,从而触发页面的刷新等操作。 Touch事件有以下四种类型:

  • UI_TOUCH_EVENT_ACTION_DOWN

  • UI_TOUCH_EVENT_ACTION_MOVE

  • UI_TOUCH_EVENT_ACTION_UP

  • UI_TOUCH_EVENT_ACTION_CANCEL

    switch(action)

    {

    case UI_TOUCH_EVENT_ACTION_DOWN:

    eventTarget->getTouchEventEmitter()->onTouchStart(touchEvent);

    break;

    case UI_TOUCH_EVENT_ACTION_MOVE:

    eventTarget->getTouchEventEmitter()->onTouchMove(touchEvent);

    break;

    case UI_TOUCH_EVENT_ACTION_UP:

    eventTarget->getTouchEventEmitter()->onTouchEnd(touchEvent);

    break;

    case UI_TOUCH_EVENT_ACTION_CANCEL:

    default:

    eventTarget->getTouchEventEmitter()->onTouchCancel(touchEvent);

    break;

    }

5、非Touch事件的传递给js侧

上文中写明,非Touch事件由ArkUINode处理,对于每个继承了ArkUINode的类,重载了onNodeEvent方法,以StackNode举例,说明RN适配层是如何区分Click事件和Touch事件。前文说明,StackNode注册了Click事件,所以通过回调,会走到StackNode的onNodeEvent部分,这里会先判断这个事件类型,这里是NODE_ON_CLICK类型,符合要求,但是对于第二个条件eventArgs[3].i32(即上文描述的arkUI传递过来的参数),如果是触屏手机,其值为2不满足eventArgs[3].i32 != 2的条件。

void StackNode::onNodeEvent(ArkUI_NodeEventType eventType,EventArgs& eventArgs)
{
    if(eventType == ArkUI_NodeEventType::NODE_ON_CLICK && eventArgs[3].i32 != 2)
    {
        onClick();
    }
    if(eventType == ArkUI_NodeEventType::NODE_ON_HOVER)
    {
        if(m_stackNodeDelegate != nullptr)
        {
            if(eventArgs[0].i32)
            {
                m_stackNodeDelegate->onHoverIn();
            }else
            {
                m_stackNodeDelegate->onHoverOut();
            }
        }
    }
}

所以此时实际上不会触发Click的事件,因此Touch事件和Click事件不会冲突。如果触发了Click事件,StackNode会通过代理StackNodeDelegate发送事件。

void StackNode::onClick()
{
    if(m_stackNodeDelegate != nullptr)
    {
        m_stackNodeDelegate->onClick();
    }
}

其中ViewComponentInstance继承了StackNodeDelegate,所以实际上走的是ViewComponentInstance的onClick函数。

namespace rnoh
{
    class ViewComponentInstance
    :public CppComponentInstance<facebook::react::ViewShardowNode>,public StackNodeDelegate
    {
    }
}

这个函数通过ViewComponentInstance的m_eventEmitter发送事件给JS,从而触发页面的刷新。

void ViewComponentInstance::onClick()
{
    if(m_eventEmitter != nullptr)
    {
        m_eventEmitter->dispatchEvent("click",[=](facebook:jsi::Runtime& runtime)
        {auto payload = facebook::jsi::Object(runtime);
                return payload;
        });
    }
}
鸿蒙版React Native启动流程

鸿蒙RN启动阶段分为RN容器创建、Worker线程启动、NAPI方法初始化、RN实例创建四个阶段,接下来加载bundle和界面渲染,类图如下所示:

React Native容器创建
  • EntryAbility

    全局Ability,App的启动入口。

  • Index.ets

    App页面入口。

  • RNApp.ets

    • 配置appKey,和JS侧registerComponent注册的appKey关联;
    • 配置初始化参数initialProps,传递给js页面;
    • 配置jsBundleProvider,指定bundle加载路径;
    • 配置ArkTS混合组件wrappedCustomRNComponentBuilder;
    • 配置rnInstanceConfig,指定开发者自定义package,注入字体文件fontResourceByFontFamily,设置BG线程开关,设置C-API开关;
    • 持有RNSurface,作为RN页面容器。
  • RNSurface.ets

RN页面容器,持有XComponent用于挂载ArkUI的C-API节点和响应手势事件。

Worker线程启动

TurboModule运行在worker线程,worker线程是在程序启动时创建。

  • WorkerThread.ts

    EntryAbility创建时会创建RNInstancesCoordinator,RNInstancesCoordinator的构造函数中获取worker线程类地址,然后调用WorkerThread的create方法启动worker线程,如下:

    const workerThread = new WorkerThread(logger, new worker.ThreadWorker(scriptUrl, { name: name }), onWorkerError)

  • RNOHWorker.ets

    WorkerThread中配置的scriptUrl即RNOHWorker.ets路径,RNOHWorker.ets内部调用setRNOHWorker.ets的setRNOHWorker方法配置worker线程收发消息通道。

  • setRNOHWorker.ets

    setRNOHWorker方法配置worker线程收发消息通道,createTurboModuleProvider方法注册系统自带和开发者自定义的运行在worker线程的TurboModule。

NAPI方法初始化
  • RNOHAppNapiBridge.cpp

Init方法是静态方法,在程序启动时调用,配置了18个ArkTS调用C++的方法,如下:

registerWorkerTurboModuleProvider,
getNextRNInstanceId, 
onCreateRNInstance,                  // 创建RN实例
onDestroyRNInstance,                 // 销毁RN实例
loadScript,                          // 加载bundle
startSurface,
stopSurface,
destroySurface,
createSurface,                       // 创建RN界面
updateSurfaceConstraints,
setSurfaceDisplayMode,
onArkTSMessage,
emitComponentEvent,                  // 给RN JS发消息
callRNFunction, 
onMemoryLevel,
updateState,
getInspectorWrapper,
getNativeNodeIdByTag
  • NapiBridge.ts

ArkTS侧RNInstance.ts、SurfaceHandle.ts调用C++的桥梁。

React Native实例创建

在RNInstance.ts中创建RN实例,分为以下步骤:

  1. 获取RNInstance的id:在RNInstanceRegistry.ets中通过NAPI调用getNextRNInstanceId方法获取。
  2. 注册ArkTS侧TurboModule:在RNInstance.ts中调用processPackage方法注册系统自带和开发者自定义的运行在UI线程上的TurboModule。
  3. 注册字体:在RNInstanceFactory.h中调用FontRegistry.h的registerFont方法注册应用侧扩展字体,接着通过图形接口注入字体信息。
  4. 注册RN官方能力和开发者自定义能力:RNInstanceFactory.h中通过PackageProvider.cpp的getPackage方法获取RN系统自带和开发者自定义TurboModule,接着注册系统View、系统自带TurboModule、开发者自定义View、开发者自定义TurboModule。
  5. 注册ArkTS混合组件:在RNInstanceFactory.cpp中注册ArkTS侧传递到C++的ArkTS组件。
  6. 初始化JS引擎:在RNInstanceInternal.cpp中初始化JS引擎Hermes或者JSVM,通过JS引擎驱动JS消息队列。
  7. 注册TM的JSI通道:在RNInstanceCAPI.cpp中调用createTurboModuleProvider创建TurboModuleProvider,注入__turboModuleProxy对象给JS侧。
  8. 注入Scheduler:在RNInstanceInternal.cpp中初始化Fabric的Scheduler对象,ReactCommon的组件绘制找到鸿蒙适配层注入的SchedulerDelegate才能进行界面绘制。
  9. 注册Fabric的JSI通道:在RNInstanceInternal.cpp中调用UIManagerBinding.cpp的createAndInstallIfNeeded方法注入nativeFabricUIManager对象给JS侧。
加载bundle

RN实例创建完毕则开始加载bundle,如下:

ArkTS侧加载bundle、C++侧加载bundle,切线程到ReactCommon的Instance.cpp中加载bundle:

RNApp.ets > RNInstance.ts > RNOHAppNapiBridge.cpp > RNInstanceInternal.cpp > Instance.cpp
总结

本文详细介绍了鸿蒙版 React Native 架构。包括按功能划分的架构组成,如 RN 应用代码、库代码、JSI、React Common、OpenHarmony 适配代码及 OS 代码等。还阐述了 Fabric、TurboModule、线程模型、命令式组件、启动流程等方面内容。启动流程分为 RN 容器创建、Worker 线程启动、NAPI 方法初始化、RN 实例创建及加载 bundle 等阶段。整体架构复杂且功能明确,为开发者提供了在鸿蒙平台上使用 React Native 的技术支持。

相关推荐
忘忧人生2 分钟前
docker 部署 java 项目详解
java·docker·容器
null or notnull29 分钟前
idea对jar包内容进行反编译
java·ide·intellij-idea·jar
言午coding2 小时前
【性能优化专题系列】利用CompletableFuture优化多接口调用场景下的性能
java·性能优化
缘友一世2 小时前
JAVA设计模式:依赖倒转原则(DIP)在Spring框架中的实践体现
java·spring·依赖倒置原则
何中应3 小时前
从管道符到Java编程
java·spring boot·后端
SummerGao.3 小时前
springboot 调用 c++生成的so库文件
java·c++·.so
组合缺一3 小时前
Solon Cloud Gateway 开发:Route 的过滤器与定制
java·后端·gateway·reactor·solon
我是苏苏3 小时前
C#高级:常用的扩展方法大全
java·windows·c#
customer084 小时前
【开源免费】基于SpringBoot+Vue.JS贸易行业crm系统(JAVA毕业设计)
java·vue.js·spring boot·后端·spring cloud·开源
_GR4 小时前
Java程序基础⑪Java的异常体系和使用
java·开发语言