Jetpack Compose UI架构

Jetpack Compose UI架构

引言

Jetpack Compose是我职业生涯中最激动人心的事。它改变了我工作和问题思考的方式,引入了易用且灵活的工具,几乎可轻松实现各种功能。

早期在生产项目中尝试了Jetpack Compose后,我迅速着迷。尽管我已有使用Compose创建UI的经验,但对新的Jetpack Compose驱动特性的组织和架构引发了许多反复。

本文目标是分享这些经验,提出可扩展、易用、易操作的架构,并接受反馈以进一步改进。

免责声明:本文仅涉及UI部分,其余应用构建遵循经典Clean Architecture方法。假设您熟悉Jetpack Compose,不深入讨论UI实现细节。

示例

为了提供具体示例,让我介绍一下本文将要介绍的示范项目。我们将要构建的应用程序允许用户在不同的地标之间进行切换并导航到它们。以下是基本流程的描述:

  • 用户可以滑动浏览地点卡片,查看有关地点的不同信息,如地点图片、名称和评分。
  • 用户可以将地点标记/取消标记为收藏。
  • 用户可以从其位置导航并规划前往所选地点的路线。为此,我们需要用户的位置权限。
  • 如果出现错误,我们希望显示一个消息提示。
  • 权限只有在用户选择规划路线时才会询问。如果用户拒绝权限,我们会导航到另一个屏幕(位置理由屏幕)。
  • 我们还希望跟踪用户与分析服务的交互。

基础知识

我对 Jetpack Compose 的最初记忆是这个方程式:UI = f(state)。这意味着 UI 是应用于某个状态的函数的结果。让我们简要回顾一下 Compose 和响应式 UI 的重要方面,特别是关于状态处理的内容:状态提升和单向数据流。

状态提升
状态提升是一种在软件开发中常用的技术,尤其在 UI 编程中,它将组件管理和操作状态的责任移至更高级的组件或更集中的位置。状态提升的目的是改善代码组织、可重用性和可维护性。你可以在这里了解更多关于状态提升的内容。

单向数据流
单向数据流(UDF)是一种设计模式,其中状态向下流动,事件向上流动。遵循单向数据流,你可以将在 UI 中显示状态的可组合项与存储和更改状态的应用程序部分解耦。

要点是,我们希望我们的 UI 组件消耗状态并发出事件。如果让我们的组件处理源自外部的事件,将打破这一规则,引入多个真相来源。重要的是,我们引入的任何“事件”都应该基于状态。

入门

首先,让我们介绍核心组件,这些是我们架构的基础。

State

我们从最明显的开始,即状态。状态可以是根据你的用例而定的任何内容。它可以是一个数据类,包含UI可能需要的所有属性,或者是一个封装接口,代表所有可能的情景。无论哪种情况,状态是你的组件或整个屏幕UI的“静态”表示,便于轻松操作。
根据我们的要求,我们有一个地点列表和一个可选的错误,所以我们的状态可能是这样的:

data class PlacesState(
    val places: List<Place> = emptyList(),
    val error: String? = null
)

Screen

屏幕是我们方程中的 f 函数。为了遵循状态提升模式,我们需要使该组件无状态并将用户交互暴露为回调。这将使我们的屏幕具有可测试性、预览性和可重用性!
我们已经有了状态,基于我们的需求,我们只需要处理两个用户交互。所以这就是我们的屏幕可能的样子。我们还包括了可能需要的其他组合状态,所以它们被提升到了屏幕外部。

@Composable
fun PlacesScreen(
    state: PlacesState,
    pagerState: PagerState,
    onFavoritesButtonClick: (Place) -> Unit,
    onNavigateToPlaceButtonClick: (Place) -> Unit
) {
    Scaffold {
        PlacesPager(
          pagerState = pagerState,
          state = state,
          onFavoritesButtonClick = onFavoritesButtonClick,
          onNavigateToPlaceButtonClick = onNavigateToPlaceButtonClick
        )
    }
}
@Composable
fun PlacesRoute(
    navController: NavController,
    viewModel: PlacesViewModel = hiltViewModel(),
) {
    // ... state collection

    LaunchedEffect(state.error) {
        state.error?.let {
            context.showError()
            viewModel.dismissError()
        }
    }

    PlacesScreen(
        state = uiState,
        onFavoritesButtonClick = //..
        onNavigateToPlaceClick = {
            when {
                permissionState.isGranted -> {
                    analyitcs.track("StartRoutePlanner")
                    navController.navigate("RoutePlanner")
                }
                permissionState.shouldShowRationale -> {
                     analytics.track("RationaleShown")
                     navController.navigate("LocationRationale")
                }
                else -> {
                    permissionState.launchPermissionRequest()
                }
            }
        }
    )
}

Route

路由(Route)是整个流程的入口。

@Composable
fun PlacesRoute(
    navController: NavController,
    viewModel: PlacesViewModel = hiltViewModel(),
) {
    // ... state collection

    LaunchedEffect(state.error) {
        state.error?.let {
            context.showError()
            viewModel.dismissError()
        }
    }

    PlacesScreen(
        state = uiState,
        onFavoritesButtonClick = //..
        onNavigateToPlaceClick = {
            when {
                permissionState.isGranted -> {
                    analyitcs.track("StartRoutePlanner")
                    navController.navigate("RoutePlanner")
                }
                permissionState.shouldShowRationale -> {
                     analytics.track("RationaleShown")
                     navController.navigate("LocationRationale")
                }
                else -> {
                    permissionState.launchPermissionRequest()
                }
            }
        }
    )
}

这是PlacesRoute函数的简化版本,但已经相当庞大。随着每个新的用户交互和基于状态的效果,这个函数的大小将会增长,使其变得更难理解和维护。另一个问题是回调函数。随着每个新的用户交互,我们将不得不在PlacesScreen的声明中添加另一个回调,这也可能会变得相当大。

另外,让我们考虑一下测试。我们可以轻松测试屏幕和ViewModel,但是Route呢?它有很多内容,不是每样东西都可以轻松模拟。首先,它与屏幕耦合在一起,所以如果没有引用它,我们将无法适当地进行单元测试。将其他组件替换为存根将需要我们将所有内容移到Route的声明中。

进行改变

让我们尝试解决我们迄今为止已经确定的这些问题

Action

在看到这些回调时,我脑海中首先想到的是如何将它们进行分组。而我当时所做的第一件事情是这样的:

sealed interface PlacesAction {
    data class NavigateToButtonClicked(val place: Place) : ParcelAction
    data class FavoritesButtonClicked(val place: Place) : ParcelAction
}

虽然这使我们能够将我们的操作分组到一个明确定义的结构中,但也带来了不同的问题。

在屏幕级别上,我们将不得不实例化这些类并调用我们的onAction回调。如果你熟悉重组(Re-composition)的工作原理,当涉及到lambda表达式时,你可能还会有冲动将其包裹在remember中,以避免不必要的UI重新渲染。

@Composable
fun PlacesScreen(
    state: PlacesState,
    onAction: (PlacesAction) -> Unit
) {
    PlacesPager(
        onFavoritesButtonClicked = { onAction(PlacesAction.FavoritesButtonClicked(it))}
    )
}

另一方面,Route还引入了另一件我不太喜欢的事情——可能是巨大的when语句。

PlacesScreen(
        state = uiState,
        onAction = { when(it) {
        FavoritesButtonClick = //..
        NavigateToPlaceClicked = {
            when {
                permissionState.isGranted -> {
                    analyitcs.track("StartRoutePlanner")
                    navController.navigate("RoutePlanner")
                }
                permissionState.shouldShowRationale -> {
                     analytics.track("RationaleShown")
                     navController.navigate("LocationRationale")
                }
                else -> {
                    permissionState.launchPermissionRequest()
                }
            }
        }
    )

所有这些都使我找到了一个更好的解决方案,那就是一个简单的数据类。

data class ParcelActions(
    val onFavoritesClicked: (Place) -> Unit = {},
    val onNavigateToButtonClicked: (Place) -> Unit = {},
)

这使我们能够在与屏幕相关的操作中引入相同的分组水平和便利性,以及一种更简单的方式将这些操作传递给相关组件。

@Composable
fun PlacesScreen(
    state: PlacesState,
    actions: PlacesActions
) {
    PlacesPager(
        onFavoritesButtonClicked = actions.onFavoritesButtonClicked,
        onNavigateToPlaceButtonClicked = actions.onNavigateToPlaceButtonClicked
    )
}

现在,在Route方面,我们还可以避免使用when语句,并引入以下实用程序,以便在每次重组时不会重新创建Actions类,使Route更加简洁。

@Composable
fun PlacesRoute(
    viewModel: PlacesViewModel,
    navController: NavController,
) {

    val uiState by viewModel.stateFlow.collectAsState()
   
    val actions = rememberPlacesActions(navController)


    LaunchedEffect(state.error) {
        state.error?.let {
            context.showError()
            viewModel.dismissError()
        }
    }

    PlacesScreen(
        state = uiState,
        actions = actions
    )

}

@Composable
fun rememberPlacesActions(
    navController: NavController,
    analytics: Analytics = LocalAnalytics.current,
    permissionState: PermissionState = rememberPermissionState(),
) : PlacesActions {
    return remember(permissionState, navController, analytics) {
        PlacesActsions(
            onNavigateToPlaceClick = {
            when {
                permissionState.isGranted -> {
                    analyitcs.track("RoutePlannerClicked")
                    navController.navigate("RoutePlanner")
                }
                permissionState.shouldShowRationale -> {
                     analytics.track("RationaleShown")
                     navController.navigate("LocationRationale")
                }
                else -> {
                    permissionState.launchPermissionRequest()
                }
            }
        }
        )
    }   
}

虽然PlacesRoute现在更加直观,但我们所做的只是将其所有的Actions逻辑移到另一个函数中,这既没有提高可读性,也没有提高可扩展性。此外,我们的第二个问题仍然存在——基于状态的效果。我们的UI逻辑现在也分散开来,引入了不一致性,并且我们并没有使其变得更具可测试性。现在是我们引入最后一个组件的时候了。

Coordinator

协调器的核心作用,正如你可能从其名称中猜到的,是协调不同的操作处理程序和状态提供者。协调器观察和响应状态变化,并处理用户操作。你可以将其视为我们流程的Compose状态。在我们简化的示例中,协调器的样子如下。
需要注意的是,由于我们的协调器现在不在可组合范围内,我们可以以更直接的方式处理一切,无需LaunchedEffect,就像我们通常在ViewModel中所做的那样,只不过这里的业务逻辑是UI逻辑。

class PlacesCoordinator(
    val viewModel: PlacesViewModel,
    val navController: NavController,
    val context: Context,
    private val permissionState: PermissionState,
    private val scope: CoroutineScope
) {

    val stateFlow = viewModel.stateFlow

    init {
        // now we can observe our state and react to it
        stateFlow.errorFlow
            .onEach { error ->
                context.toast(error.message)
                viewModel.dismissError()
            }.launchIn(scope)
    }

    // and handle actions
    fun navigateToRoutePlanner() {
        when {
            permissionState.isGranted -> {
                viewModel.trackRoutePlannerEvent()
                navController.navigate("RoutePlanner")
            }
            permissionState.shouldShowRationale -> {
                viewModel.trackRationaleEvent()
                navController.navigate("LocationRationale")
            }
            else -> permissionState.launchPermissionRequest()
        }
    }

}

我们的Action将修改成如下

@Composable
fun rememberPlacesActions(
   coordinator: PlacesCoordinator
) : PlacesActions {
    return remember(coordinator: PlacesCoordinator) {
        PlacesActsions(
            onFavoritesButtonClicked = coordinator.viewModel::toggleFavorites,
            onNavigateToPlaceButtonClicked = coordinator::navigateToRoutePlanner
        )
}

我们的Route修改如下

@Composable
fun PlacesRoute(
    coordinator: PlacesCoordinator = rememberPlacesCoordinator()
) {

    val uiState by coordinator.stateFlow.collectAsState()
   
    val actions = rememberPlacesActions(coordinator)

    PlacesScreen(
        state = uiState,
        actions = actions
    )

}

在我们的示例中,PlacesCoordinator 现在负责在我们的功能流中发生的UI逻辑。由于它了解不同的状态,我们可以轻松地对状态变化做出反应,并为每个用户交互构建条件逻辑。如果交互很直接,我们可以轻松地将其委托给相关的组件,比如 ViewModel。
通过拥有协调器,我们还可以控制向屏幕公开哪些状态。如果我们有多个 ViewModel 或者 ViewModel 状态对于我们正在处理的屏幕来说过于庞大,我们可以将这些状态组合起来或者公开部分状态。

val screenStateFlow = viewModel.stateFlow.map { PartialScreenState() }
    // or
    val screenStateFlow = combine(vm1.stateFlow, vm2.stateFlow) { ScreenStateFlow() }

另一个好处是,整个流程的UI逻辑现在与Route解耦,这意味着我们可以将我们的Coordinator作为另一个Route的一部分使用,而无需复制重要内容并保持屏幕部分无状态。

@Composable
fun TwoPanePlacesRoute(
    detailsCoordinator: PlacesDetailsCoordinator,
    placesCoordinator: PlacesCoordinator
) {
    
    TwoPane(
        first = {
            PlacesScreen(
                state = placesCoordinator.state,
                actions = rememberPlacesActions(placesCoordinator)
            )
        },
        second = {
            PlaceDetailsScreen(
                state = detailsCoordinator. state,
                actions = rememberDetailsActions(detailsCoordinator)
            )
        }
    )
}

最后,现在我们可以通过测试实现它的组件来测试我们的UI逻辑。让我们看看如何通过使用我们的“当权限被拒绝时导航到理由屏幕”来测试我们的Coordinator。
这部分假设您对如何测试Composable组件有一些了解。

fun test_NavigateToRatinoleIfPermissionWasDeniedBefore() {
     composeRule.setContent {
            // 1
            ComposableUnderTest(
                coordinator = rememberPlacesCoordinator(
                    navController = testNavController,
                    viewModel = NearbyPlacesViewModel()
                )
            )
        }
        
        // 2
        composeRule.onNode(hasText("Navigate")).performClick()

        // 3
        Assert.assertEquals(
            "locationRationale",
            navController.currentBackStackEntry?.destination?.route
        )
}

让我们快速浏览一下这个测试:

  1. 首先,我们发出了作为测试对象的Composable UI。这个UI的结构很简单,直接调用了我们的Coordinator。
 @Composable
private fun ComposableUnderTest(coordinator: NearbyPlacesCoordinator) {
    NavHost(
        navController = coordinator.navController,
        startDestination = "home"
    ) {
        composable("home") {
            Button(onClick = { coordinator.navigateToPlace(Place.Mock) }) {
                Text(text = "Navigate")                
            }
         }
        composable("locationRationale") {
            Text(text = "No permission")
        }
    }
}

  1. 其次,我们以编程方式点击“导航”按钮,触发操作并让Coordinator处理它。
  2. 最后,我们通过检查NavHostController中的当前目标是否与我们预期的目标一致来验证我们的假设是否有效,我们的实现是否正常工作。

总结一下我们进行的重构和取得的成就:

  1. 我们的Screen仍然完全是无状态的。它仅依赖于作为函数参数传递的内容。所有用户交互都通过Actions暴露给其他组件处理。
  2. Route现在在导航图中充当简单的入口点。它收集状态,在重新组合过程中记住我们的操作。
  3. Coordinator现在正在做大部分繁重的工作:响应状态变化并将用户交互委派给其他相关组件。它完全与Screen和Route解耦,可以在另一个路由中重用并单独测试。
    以下图表展示了我们现在拥有的数据流程。

问答时间

每个Compose屏幕都需要一个协调器吗?
简短的回答是:这取决于情况!对于一个非常简单的流程,比如一个带有两个操作的对话框,可能有点过于复杂。你可能会完全取消操作数据类,将这些操作放在路由中处理。对于一个随着时间复杂度增加的屏幕,我认为从一开始就值得投资,或者在看到路由增长时开始进行重构。

LaunchedEffect 是否已被“弃用”?
当然没有!同样,一个没有协调器的简单屏幕可以使用LaunchedEffect来对状态变化做出反应,这是完全可以的。当UI逻辑存在于屏幕层并在屏幕层中终止时,您仍然可以在屏幕中使用LaunchedEffect,例如动画。

路由没有做太多事情
是的,在我们的示例中,路由在责任方面相当轻量级。但将其作为导航入口意味着更多。许多不是基于状态的效果都属于路由的处理范畴。例如,我们可以使用SideEffect来调整颜色,或者放置BackHandler来拦截返回按钮的按下,这在屏幕内并不总是合适。

协调器会像路由一样随着时间而增长吗?
很可能是的。这可能是它正在做太多事情的迹象,其中一些事情可以提取到另一个具有状态的组件中,甚至是另一个协调器中。就像您从屏幕中提取不同的UI组件来封装一些UI一样,您可以构建其他组件或协调器来封装UI逻辑。

资源

Jetpack Compose UI Architecture IDE Plugin:https://plugins.jetbrains.com/plugin/19034-jetpack-compose-ui-architecture-templates
compose ui架构文档:https://levinzonr.github.io/compose-ui-arch-docs/

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mfbz.cn/a/92994.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

信息化发展2

信息系统生命周期 1 、软件的生命周期通常包括&#xff1a;可行性分析与项目开发计划、需求分析、概要设计、详细设计、编码、测试、维护等阶段。 2 、信息系统的生命周期可以简化为&#xff1a;系统规划&#xff08;可行性分析与项目开发计划&#xff09;&#xff0c;系统分析…

基于Pytorch的神经网络部分自定义设计

一、基础概念&#xff08;学习笔记&#xff09; &#xff08;1&#xff09;训练误差和泛化误差[1] 本质上&#xff0c;优化和深度学习的目标是根本不同的。前者主要关注的是最小化目标&#xff0c;后者则关注在给定有限数据量的情况下寻找合适的模型。训练误差和泛化误差通常不…

机器学习十大算法之七——随机森林

0 引言 集成学习&#xff08;ensemble learning&#xff09;是时下非常流行的机器学习算法&#xff0c;它本身不是一个单独的机器学习算法&#xff0c;而是通过在数据上构建多个横型&#xff0c;集成所有模型的建模结果&#xff0c;基本上所有的机器学习领域都可以看到集成学习…

Docker部署gogs仓库

Docker部署gogs Git仓库 拉取镜像 docker pull gogs/gogs查看本地镜像 docker images启动gogs仓库服务 创建数据挂在目录 我在/root目录下创建gogs挂在目录 mkdir gogs启动gogs docker run --namegogs -d -p 10022:22 -p 10880:3000 -v /root/gogs:/data gogs/gogs10022…

破除“中台化”误区,两大新原则考核中后台

近年来&#xff0c;“中台化”已成为许多企业追求的目标&#xff0c;旨在通过打通前后台数据和业务流程&#xff0c;提升运营效率和创新能力。然而&#xff0c;在实施过程中&#xff0c;一些误解可能导致“中台化”未能如预期般发挥作用。本文将探讨这些误解&#xff0c;并提出…

兄弟,王者荣耀的段位排行榜是通过Redis实现的?

目录 一、排行榜设计方案1、数据库直接排序2、王者荣耀好友排行 二、Redis实现计数器1、什么是计数器功能&#xff1f;2、Redis实现计数器的原理&#xff08;1&#xff09;使用INCR命令实现计数器&#xff08;2&#xff09;使用INCRBY命令实现计数器 三、通过Redis实现“王者荣…

Pycharm链接远程mysql报错

Pycharm链接远程mysql配置及相应报错如下&#xff1a; 解决方法&#xff1a; 去服务器确认Mysql版本号&#xff1a; 我的Mysql为5.7.43&#xff0c;此时Pycharm mysql驱动为8.0版本&#xff0c;不匹配&#xff0c;所以需要根据实际的版本选择对应的驱动&#xff1b;选择对应的版…

【Java架构-包管理工具】-Maven私服搭建-Nexus(三)

本文摘要 Maven作为Java后端使用频率非常高的一款依赖管理工具&#xff0c;在此咱们由浅入深&#xff0c;分三篇文章&#xff08;Maven基础、Maven进阶、私服搭建&#xff09;来深入学习Maven&#xff0c;此篇为开篇主要介绍Maven私服搭建-Nexus 文章目录 本文摘要1. Nexus安装…

Mr. Cappuccino的第64杯咖啡——Spring循环依赖问题

Spring循环依赖问题 什么是循环依赖问题示例项目结构项目代码运行结果 Async注解导致的问题使用Lazy注解解决Async注解导致的问题开启Aop使用代理对象示例项目结构项目代码运行结果 Spring是如何解决循环依赖问题的原理源码解读 什么情况下Spring无法解决循环依赖问题 什么是循…

计算机组成原理学习笔记-精简复习版

一、计算机系统概述 计算机系统硬件软件 计算机硬件的发展&#xff1a; 第一代计算机&#xff1a;(使用电子管)第二代计算机&#xff1a;(使用晶体管)第三代计算机&#xff1a;(使用较小规模的集成电路)第四代计算机&#xff1a;(使用较大规模的集成电路) 冯诺依曼体系结构…

Kotlin协程flow的debounce参数timeoutMillis特性

Kotlin协程flow的debounce参数timeoutMillis特性 <dependency><groupId>org.jetbrains.kotlinx</groupId><artifactId>kotlinx-coroutines-core</artifactId><version>1.7.3</version><type>pom</type></dependency&…

error: can‘t find Rust compiler

操作系统 win11 pip install -r requirements.txt 报错如下 Using cached https://pypi.tuna.tsinghua.edu.cn/packages/56/fc/a3c13ded7b3057680c8ae95a9b6cc83e63657c38e0005c400a5d018a33a7/pyreadline3-3.4.1-py3-none-any.whl (95 kB) Building wheels for collected p…

聚观早报 | 云鲸扫拖机器人J4体验;芯科科技第三代无线开发平台

【聚观365】8月24日消息 云鲸扫拖机器人J4体验 芯科科技推出第三代无线开发平台 英伟达与VMWare宣布扩大合作 万物新生&#xff08;爱回收&#xff09;2023年二季度财报 充电桩需求增长带动汽车后服务市场 云鲸扫拖机器人J4体验 家庭卫生清洁是每个人都无法回避的事情&am…

老网工的爱情故事二:从VPN到SD-WAN,爱情与技术的升华

— 前言 — 为什么爱情不能像设置VLAN一样 把不同的“IP”的人绑在一起&#xff1f; 为什么周围的事物 不能像创建ACL那样随心所欲的控制&#xff1f; 为什么相爱的人远在天涯 不能像做VPN一样拉到近在咫尺&#xff1f; 为什么你我之间没有一个边界路由呢&#xff1f; 我已经给…

ISIS路由协议

骨干区域与非骨干区域 凡是由级别2组建起来的邻居形成骨干区域&#xff1b;级别1就在非骨干区域&#xff0c;骨干区域有且只有一个&#xff0c;并且需要连续&#xff0c;ISIS在IP环境下目前不支持虚链路。 路由器级别 L1路由器只能建立L1的邻居&#xff1b;L2路由器只能建立L…

Python第三方库纵览

Python第三方库纵览 知识点 更广泛的Python计算生态&#xff0c;只要求了解第三方库的名称&#xff0c;不限于以下领域: 网络爬虫、数据分析、文本处理、数据可视化、用户图形界面、机器学习、Web开发、游戏开发等 知识导图 1、网络爬虫方向 网络爬虫是自动进行HTTP访问并捕…

spring boot 项目整合 websocket

1.业务背景 负责的项目有一个搜索功能&#xff0c;搜索的范围几乎是全表扫&#xff0c;且数据源类型贼多。目前对搜索的数据量量级未知&#xff0c;但肯定不会太少&#xff0c;不仅需要搜索还得点击下载文件。 关于搜索这块类型 众多&#xff0c;未了避免有个别极大数据源影响整…

Maven之高版本的 lombok 和 tomcat 7 插件冲突问题

高版本的 lombok 和 tomcat 7 插件冲突问题 在开发期间&#xff0c;当我们使用 tomcat7-maven-plugin 来作为运行环境运行我们项目使&#xff0c;如果我们项目中使用了 1.16.20 及以上版本的 lombok 包&#xff0c;项目启动时会报错&#xff1a; for annotations org.apache.…

ELK之LogStash介绍及安装配置

一、logstash简介 集中、转换和存储数据 Logstash 是免费且开放的服务器端数据处理管道&#xff0c;能够从多个来源采集数据&#xff0c;转换数据&#xff0c;然后将数据发送到您最喜欢的“存储库”中。 Logstash 能够动态地采集、转换和传输数据&#xff0c;不受格式或复杂度的…

完美解决Ubuntu网络故障,连接异常,IP地址一直显示127.0.0.1

终端输入ifconfig显示虚拟机IP地址为127.0.0.1&#xff0c;具体输出内容如下&#xff1a; wxyubuntu:~$ ifconfig lo: flags73<UP,LOOPBACK,RUNNING> mtu 65536inet 127.0.0.1 netmask 255.0.0.0inet6 ::1 prefixlen 128 scopeid 0x10<host>loop txqueuelen …
最新文章