Flow深入浅出系列之在ViewModels中使用Kotlin Flows

  • Flow深入浅出系列之在ViewModels中使用Kotlin Flows
  • Flow深入浅出系列之更聪明的分享 Kotlin Flows
  • Flow深入浅出系列之使用Kotlin Flow自动刷新Android数据的策略

Flow深入浅出系列之在ViewModels中使用Kotlin Flows

Flow出现后,LiveData仍然可以用,并且可以彼此转换。

在Android应用程序中加载UI数据可能会很具有挑战性。需要考虑各个屏幕的生命周期以及配置更改导致活动销毁和重新创建。

应用程序的各个屏幕会不断在互动和隐藏之间切换,因为用户在应用程序中导航,从一个应用程序切换到另一个应用程序,或者设备屏幕锁定或解锁。每个组件需要公平竞争,并且只有在获得“球”时才执行活动工作。

配置更改发生在各种情况下:当更改设备方向、将应用程序切换到多窗口模式或调整其窗口大小时、更改默认语言环境或字体大小等时。

效率目标

要实现在Activity和Fragment中有效地加载数据,以实现最佳用户体验,应考虑以下问题:

  1. 缓存:已成功加载并仍然有效的数据应立即提供,并且不应第二次加载。特别是当现有的Activity或Fragment再次变得可见时,或在配置更改后重新创建Activity时;
  2. 避免后台工作:当Activity或Fragment变得不可见(从STARTED状态移至STOPPED状态)时,任何正在进行的加载工作都应该暂停或取消,以节省资源。这对于诸如位置更新或任何种类的定期刷新之类的无尽数据流尤为重要;
  3. 在配置更改期间不中断工作:这是第二个目标的例外。在配置更改期间,Activity会被新实例替换,同时保留其状态,因此当旧实例被销毁时取消正在进行的工作,以便在创建新实例时立即重新启动它将是适得其反的。

ViewModel和LiveData

为了帮助开发人员使用可管理复杂性的代码实现这些目标,Google在2017年发布了第一个架构组件库,即ViewModel和LiveData。这是在Kotlin成为开发Android应用程序的推荐编程语言之前。

ViewModel是跨配置更改保存的对象。它们对实现目标1和3很有用:在配置更改期间可以在其中运行不间断的加载操作,而所得到的数据可以缓存在其中,并与当前附加到其中的一个或多个Fragment / Activity共享。

LiveData是一个简单的可观察数据持有器类,也是生命周期感知的。只有当其生命周期至少处于STARTED(可见)状态时,才会向观察者分派新值,观察者会自动取消注册,这非常方便,以避免内存泄漏。LiveData对实现目标1和2很有用:它缓存其所持有的数据的最新值,该值将自动分派给新观察者。此外,当STARTED状态中没有更多已注册的观察者时,它会被通知,从而可以避免执行不必要的后台工作。
一个图表说明了ViewModel作用域与Activity生命周期的关系
如果您是经验丰富的Android开发人员,您可能已经知道所有这些。但重要的是要回顾这些功能,以便将其与Flow的功能进行比较。

LiveData +协程

与RxJava等响应式流解决方案相比,LiveData本身非常有限:

它只处理将数据传递到主线程和从主线程传递数据,而将管理后台线程的负担留给开发人员。
值得注意的是,map()运算符在主线程上执行其转换函数,无法用于执行I / O操作或重度CPU工作。在这种情况下,需要与手动启动后台线程组合使用switchMap()运算符,即使只需要在主线程上发布单个值也是如此。
LiveData仅提供3个变换运算符:map()switchMap()distinctUntilChanged()。如果需要更多,请使用MediatorLiveData自己实现它们。
为了帮助克服这些限制,Jetpack库还提供了LiveData到其他技术(如RxJava或Kotlin的协程)的桥梁。

在我看来,最简单和最优雅的桥梁是androidx.lifecycle:lifecycle-livedata-ktx Gradle依赖项提供的LiveData协程构建器函数。此函数类似于Kotlin Coroutines库中的flow {}构建器函数,并允许将协程智能地包装为LiveData实例:

val result: LiveData<Result> = liveData {val data = someSuspendingFunction()emit(data)
}
  • 您可以使用协程和协程上下文的所有强大功能以同步方式编写异步代码,而不需要回调,根据需要自动在线程之间切换;
  • 通过从协程调用emit()emitSource()暂停函数向LiveData观察者分派新值;
  • 协程使用与LiveData实例绑定的特殊范围和生命周期。当LiveData变得不活跃(没有更多处于STARTED状态的观察者时),协程将自动取消,以达到目标2而不需要额外工作;
  • 协程的取消实际上将在LiveData变得不活跃5秒钟后延迟处理,以优雅地处理配置更改:如果新的Activity立即替换旧的Activity并且LiveData在超时之前再次活动,则不会发生取消,将避免无必要的重启成本(目标3);
  • 如果用户回到屏幕并且LiveData再次变为活动状态,则协程将自动重新启动,但仅在先前被取消之前。一旦协程完成,它将不会再次重新启动,这样可以避免在输入未更改的情况下加载相同的数据两次(目标1)。

总之, 通过使用LiveData coroutines builder,默认情况下获得最佳表现和最简单的代码。

如果存储库提供以Flow形式返回值流的挂起函数,而不是返回单个值的挂起函数,该怎么办?使用asLiveData()扩展函数也可以将其转换为LiveData,并利用所有上述功能:

val result: LiveData<Result> = someFunctionReturningFlow().asLiveData()

在幕后,asLiveData()还使用LiveData协程构建器创建一个简单的协程,该协程在LiveData处于活动状态时收集Flow:

fun <T> Flow<T>.asLiveData(): LiveData<T> = liveData {collect {emit(it)}
}

但让我们停顿一会儿——Flow到底是什么,它能否完全替代LiveData?

介绍Kotlin的Flow

查理·卓别林(Charlie Chaplin)背对他的妻子,标签为LiveData,看着一个标签为Flow的迷人女人

Flow是比较新潮的类似于异步计算流的值流类,属于Kotlin的Coroutines库,于2019年推出。它的概念与RxJava Observables类似,但基于协程,并且具有更简单的API。

首先,只有冷流可用:无状态的流,在每次观察者在协程范围内开始收集值时根据需要创建。每个观察者都有自己的值序列,它们不共享。

后来,新增了新的热流子类型 SharedFlow 和 StateFlow,并且在 Coroutines 库的 1.4.0 版本中作为稳定的 API 发布。

SharedFlow 允许发布值,这些值将广播给所有观察者。它可以管理可选的重放缓存和/或缓冲区,并基本上替代了所有已弃用的 BroadcastChannel API 的变种。

StateFlow 是 SharedFlow 的一种专门优化的子类,它仅存储并重放最新的值。听起来很熟悉吧?

StateFlow 和 LiveData 有很多共同点

  • 它们都是可观察的类。
  • 它们存储并向任意数量的观察者广播最新的值。
  • 它们迫使您尽早捕获异常:LiveData 回调中未捕获的异常会停止应用程序。热流中未捕获的异常会结束流,无法重新启动,即使使用 .catch() 操作符也不行。

但它们也有重要的区别

  • MutableStateFlow 需要初始值,MutableLiveData 不需要(注意:可以使用 MutableSharedFlow(replay = 1) 来模拟没有初始值的 MutableStateFlow,但其实现效率稍低)。
  • StateFlow 总是使用 Any.equals() 方法来过滤掉重复的相同值,LiveData 除非与 distinctUntilChanged() 操作符结合使用,否则不会(注意:SharedFlow 也可用于防止这种行为)。
  • StateFlow 不具备生命周期感知能力。但是,可以从生命周期感知的协程中收集 Flow,这需要更多的代码设置,而不使用 LiveData(下面将详细介绍)。
  • LiveData 使用版本控制来跟踪已向哪个观察者分派了哪个值。这样可以避免将相同的值两次发送给同一个观察者,因为当观察者返回到 STARTED 状态时,它将不再进行分派。
  • StateFlow 没有版本控制。每次协程收集 Flow 时,它都被视为新的观察者,并且始终首先接收最新的值。这可能导致执行重复的工作,我们将在下面的案例研究中看到。

观察 LiveData vs 收集 Flow

从 Activity 或 Fragment 观察 LiveData 实例非常简单:

viewModel.results.observe(viewLifecycleOwner) { data ->displayResult(data)
}

这是一次性操作,LiveData 会确保将流与观察者的生命周期同步。

与此对应的 Flow 操作称为收集(collecting),并且收集必须在协程中完成。因为 Flow 本身不具备生命周期感知能力,所以与生命周期的同步责任被转移到了收集 Flow 的协程。

要创建一个在 Activity/Fragment 处于 STARTED 状态时进行收集并在 Activity/Fragment 销毁时自动取消收集的生命周期感知协程,可以使用以下代码:

viewLifecycleOwner.lifecycleScope.launchWhenStarted {viewModel.result.collect { data ->displayResult(data)}
}

但是,这段代码存在一个主要限制:它只适用于不由通道或缓冲区支持的冷流。这样的流仅由收集它的协程驱动:当 Activity/Fragment 进入 STOPPED 状态时,协程将暂停,Flow 生产者也将暂停,直到协程恢复为止,期间不会发生其他任何事情。

然而,还有其他类型的流:

  • 热流始终处于活动状态,并将结果分派给所有当前的观察者(包括暂停的观察者);
  • 基于回调或通道的冷流在开始收集时订阅活动数据源,并且只在取消收集时停止订阅(而不是暂停)。

对于这些情况,即使挂起了收集 Flow 的协程,底层的流生成器也将保持活动状态,在后台缓冲新的结果。这样会浪费资源,无法实现目标 #2。

像一位坐在长凳上的福雷斯特·冈普说的:“生活就像一盒巧克力,你永远不知道要收集哪种流。”
需要实现一种更安全的收集任何类型流的方式。执行收集的协程在 Activity/Fragment 变为不可见时必须被取消,并在再次变为可见时重新启动,就像 LiveData 协程构建器所做的那样。为此,在编写本文时,lifecycle:lifecycle-runtime-ktx:2.4.0(仍为 alpha 版本)中引入了新的 API:

viewLifecycleOwner.lifecycleScope.launch {viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {viewModel.result.collect { data ->displayResult(data)}}
}

或者可以选择以下方式:

viewLifecycleOwner.lifecycleScope.launch {viewModel.result.flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED).collect { data ->displayResult(data)}
}

正如您所见,为了在Activity或Fragment中以相同的安全性和效率观察结果,使用LiveData更简单。

将ViewModel中的LiveData替换为StateFlow

让我们回到ViewModel。我们已经确定使用LiveData是一种简单高效的方法来异步获取数据:

val result: LiveData<Result> = liveData {val data = someSuspendingFunction()emit(data)
}

如果我们想要使用StateFlow来达到相同的效果,应该怎么实现呢?Jose Alcérreca撰写了一个详细的迁移指南来帮助解答这个问题。长话短说,对于上述的用例,等效的代码是:

val result: Flow<Result> = flow {val data = someSuspendingFunction()emit(data)
}.stateIn(scope = viewModelScope,started = SharingStarted.WhileSubscribed(5000L),initialValue = Result.Loading
)

stateIn()操作符将我们的冷流转换为热流,能够在多个观察者之间共享单个结果。由于使用了SharingStarted.WhileSubscribed(5000L),当第一个观察者订阅时,热流会延迟启动,并在最后一个观察者取消订阅5秒后被取消,这样可以避免在后台执行不必要的工作,同时也考虑到了配置更改。

不幸的是,与LiveData协程构建器相反,当新的观察者在此闲置期间订阅时,共享协程会自动重新启动上游流,即使在先前的收集过程中已经达到末尾。对于上面的示例,这意味着如果Activity/Fragment在隐藏超过5秒后变为可见,someSuspendingFunction()将始终再次运行。

目标#1未实现:数据的确进行了缓存(StateFlow将存储并重放最新值),但这不会阻止其被加载和传递第二次。

看起来我们实现了3个目标中的2个,并使用了稍微复杂一些的代码来复制LiveData的大部分行为。

还有另一个小的关键区别:每次启动新的流收集时,StateFlow始终立即向观察者传递最新的结果。即使在前一个收集过程中已经将相同的结果传递给了同一个Activity/Fragment。因为与LiveData不同,StateFlow不支持版本控制,每次流收集都被视为全新的观察者。

这有问题吗?对于这个简单的用例来说,实际上并没有问题:Activity或Fragment可以通过额外的检查来避免在数据未更改时更新视图。

viewLifecycleOwner.lifecycleScope.launch {viewModel.result.flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED).distinctUntilChanged().collect { data ->displayResult(data)}
}

但在更复杂的实际用例中可能会出现问题,我们将在下一节中看到。

在ViewModel中使用StateFlow作为触发器

常见的情况是在ViewModel中使用基于触发器的方法来加载数据:每次触发器的值更新时,数据被刷新。

使用MutableLiveData,这个方法可以很好地工作:

class MyViewModel(repository: MyRepository) : ViewModel() {private val trigger = MutableLiveData<String>()fun setQuery(query: String) {trigger.value = query}val results: LiveData<SearchResult>= trigger.switchMap { query ->liveData {emit(repository.search(query))}}
}

刷新时, switchMap()操作符将连接观察者到新的底层LiveData源,替换旧的源。因为上面的示例使用LiveData协程构建器,所以前一个LiveData源会在从其观察者断开连接后自动取消关联的协程,时间为5秒钟。通过一小段延迟,可以避免使用过时的值的问题。
由于LiveData具有版本控制,MutableLiveData触发器只会将新值分派一次给switchMap()操作符,只要至少有一个活动观察者。稍后,当观察者变得不活跃然后再次变得活跃时,最新底层LiveData源的工作将继续上次离开的地方。
代码足够简单,可以高效地实现所有目标。

现在让我们看看是否可以使用MutableStateFlow实现相同的逻辑,而不是使用MutableLiveData。

天真的方法

class MyViewModel(repository: MyRepository) : ViewModel() {private val trigger = MutableStateFlow("")fun setQuery(query: String) {trigger.value = query}val results: Flow<SearchResult> = trigger.mapLatest { query ->repository.search(query)}.stateIn(scope = viewModelScope,started = SharingStarted.WhileSubscribed(5000L),initialValue = SearchResult.EMPTY)
}

MutableLiveDataMutableStateFlow的API非常接近,触发代码看起来几乎相同。最大的区别在于使用mapLatest()变换函数,该函数对于单个返回值(对于多个返回值,应使用flatMapLatest())等效于LiveData的switchMap()

mapLatest()的工作方式类似于map(),但是它不会按顺序完全执行所有输入值的转换,而是立即消耗输入值,并且转换在单独的协程中异步执行。当在上游流中发出新值时,如果先前的值的转换协程仍在运行,则会立即取消它,并启动一个新的值来替换它。这样,可以避免在过时的值上进行工作。

到目前为止都很好。然而,这里有代码的一个主要问题:由于StateFlow不支持版本控制,当流集合重新启动时,触发器将重新发出最新的值。每次Activity / Fragment再次可见时,它都会发生超过5秒钟的时间。

当触发器重新发出相同的值时,mapLatest()转换将再次运行,还会用相同的参数再次调用存储库,即使结果已经被交付和缓存了!错过了目标#1:不应再次加载仍然有效的数据。

防止重新发射最新的触发器值

接下来的问题是:我们应该阻止这种重新发射,怎么办?StateFlow已经处理了从流集合内部去重的值,并且distinctUntilChanged()操作符也可以对其他类型的流执行相同的操作。但是,不存在用于在同一流的多个集合之间去重值的标准运算符,因为流集合应该是自包含的。这是LiveData的一个主要区别。

特定情况下,在使用stateIn()操作符共享多个观察者之间的Flow时,发出的值将被缓存,并且任何给定时间只会有最多一个协程收集源Flow。看起来很诱人,可以在某些操作符函数周围进行黑客攻击,以记住以前集合的最新值,以便在开始新集合时跳过它:

//不要在家里(或工作中)做这件事
fun <T> Flow<T>.rememberLatest(): Flow<T> {var latest: Any? = NULLreturn flow {collectIndexed { index, value ->if (index != 0 || value !== latest) {emit(value)latest = value}}}
}

备注:细心的读者注意到,通过将MutableStateFlow替换为Channel(capacity = CONFLATED),然后使用receiveAsFlow()将其转换为Flow,可以实现相同的行为。通道永远不会重新发射值。

不幸的是,上述逻辑是有缺陷的,当下游流转换在完成之前被取消时,它将不能正常工作。

代码假定在emit(value)返回后,该值已被处理,如果流集合重新启动,则不应再发射。但是,在使用缓冲的Flow操作符时,例如mapLatest()时,上面的代码不起作用,并且`emit(value)将立即返回,同时转换在异步执行。这意味着没有办法知道downstream flow是否完全处理了值。如果流集合在异步转换的中途被取消,则需要在流集合重新启动时重新发射最新的值,以便恢复该转换,否则该值将丢失!

简而言之:在ViewModel中使用StateFlow作为触发器会导致每次Activity / Fragment再次可见时重复执行工作,而且没有简单的方法可以避免它。

这就是为什么在ViewModel中将LiveData用作触发器时,LiveData优于StateFlow的原因,即使这些差异未在Google的“Kotlin Flow的高级协程”codelab中提到,这表明Flow实现与LiveData一样的方式相同。 它不是。

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

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

Vue动态class

注意在自定义组件上绑定class会添加到该组件的根元素上面 1.对象语法 传入class对象v-bind:class 指令也可以与普通的 class attribute 共存可以动态修改class的值可以绑定一个计算数据来实现 1.传入class对象 <script src"./vue.js"></script><di…

【第二天】C++类和对象解析:构造函数、析构函数和拷贝构造函数的完全指南

一、类的引出概述 在c语言结构体中&#xff0c;行为和属性是分开的&#xff0c;万一调用错误&#xff0c;将会导致问题发生。c中类将数据和方法封装在一起&#xff0c;加以权限区分&#xff0c;用户只能通过公共方法 访问 私有数据。 二、封装 封装特性包含两个方面&#xff0…

【C++】-c++的类型转换

&#x1f496;作者&#xff1a;小树苗渴望变成参天大树&#x1f388; &#x1f389;作者宣言&#xff1a;认真写好每一篇博客&#x1f4a4; &#x1f38a;作者gitee:gitee✨ &#x1f49e;作者专栏&#xff1a;C语言,数据结构初阶,Linux,C 动态规划算法&#x1f384; 如 果 你 …

自然语言处理---Self Attention自注意力机制

Self-attention介绍 Self-attention是一种特殊的attention&#xff0c;是应用在transformer中最重要的结构之一。attention机制&#xff0c;它能够帮助找到子序列和全局的attention的关系&#xff0c;也就是找到权重值wi。Self-attention相对于attention的变化&#xff0c;其实…

使用vscode搭建虚拟机

首先vscode插件安装 名称: Remote - SSH ID: ms-vscode-remote.remote-ssh 说明: Open any folder on a remote machine using SSH and take advantage of VS Codes full feature set. 版本: 0.51.0 VS Marketplace 链接: https://marketplace.visualstudio.com/items?it…

黑豹程序员-架构师学习路线图-百科:MVC的演变终点SpringMVC

MVC发展史 在我们开发小型项目时&#xff0c;我们代码是混杂在一起的&#xff0c;术语称为紧耦合。 如最终写ASP、PHP。里面既包括服务器端代码&#xff0c;数据库操作的代码&#xff0c;又包括前端页面代码、HTML展现的代码、CSS美化的代码、JS交互的代码。可以看到早期编程就…

基于SSM的快递管理系统设计与实现

末尾获取源码 开发语言&#xff1a;Java Java开发工具&#xff1a;JDK1.8 后端框架&#xff1a;SSM 前端&#xff1a;采用JSP技术开发 数据库&#xff1a;MySQL5.7和Navicat管理工具结合 服务器&#xff1a;Tomcat8.5 开发软件&#xff1a;IDEA / Eclipse 是否Maven项目&#x…

proteus中仿真arduino的水位测试传感器

一、原理介绍 我们这里使用的水位传感器&#xff0c;只能说是一个小实验用途的水位传感器。我们首先上图 如上图所示&#xff0c;线没有连接&#xff0c;传感器由许5对裸露在外的铜线片作为传感部分&#xff0c;当浸入水中时这些铜线片会被水桥接。 这些被水连接起来的铜线&a…

【NPM】vuex 数据持久化库 vuex-persistedstate

在 GitHub 上找到&#xff1a;vuex-persistedstate。 安装 npm install --save vuex-persistedstate使用 import { createStore } from "vuex"; import createPersistedState from "vuex-persistedstate";const store createStore({// ...plugins: [cr…

android studio打开flutter项目报红

一、android studio打开flutter项目报红&#xff0c;如下图&#xff1a; 二、解决方法&#xff1a; 2.1 在这个build.gradle添加以下代码&#xff0c;如图&#xff1a; 2.2 在build.gradle最顶部添加如下代码&#xff1a; def localProperties new Properties() def localPr…

基于指数分布优化的BP神经网络(分类应用) - 附代码

基于指数分布优化的BP神经网络&#xff08;分类应用&#xff09; - 附代码 文章目录 基于指数分布优化的BP神经网络&#xff08;分类应用&#xff09; - 附代码1.鸢尾花iris数据介绍2.数据集整理3.指数分布优化BP神经网络3.1 BP神经网络参数设置3.2 指数分布算法应用 4.测试结果…

STM32不使用 cubeMX实现外部中断

这篇文章将介绍如何不使用 cubeMX完成外部中断的配置和实现。 文章目录 前言一、文件加入工程二、代码解析exti.cexti.hmain.c 注意&#xff1a;总结 前言 实验开发板&#xff1a;STM32F103C8T6。所需软件&#xff1a;keil5 &#xff0c; cubeMX 。实验目的&#xff1a;如何不…

AdaBoost:增强机器学习的力量

一、介绍 机器学习已成为现代技术的基石&#xff0c;为从推荐系统到自动驾驶汽车的一切提供动力。在众多机器学习算法中&#xff0c;AdaBoost&#xff08;自适应增强的缩写&#xff09;作为一种强大的集成方法脱颖而出&#xff0c;为该领域的成功做出了重大贡献。AdaBoost 是一…

面试题:谈谈过滤器和拦截器的区别?

文章目录 一、拦截器和过滤器的区别二、拦截器和过滤器的代码实现1、拦截器2、过滤器 三、总结1、什么是Filter及其作用介绍2、Filter API介绍3、Filter链与Filter生命周期 四、拦截器五、过滤器和拦截器的区别 一、拦截器和过滤器的区别 1、拦截器(Interceptor)只对action请求…

【鸿蒙软件开发】ArkTS常见组件之单选框Radio和切换按钮Toggle

文章目录 前言一、Radio单选框1.1 创建单选框1.2 添加Radio事件1.3 场景示例二、切换按钮Toggle2.1 创建切换按钮2.2 创建有子组件的Toggle2.3 自定义样式selectedColor属性switchPointColor属性 2.4 添加事件2.5 示例代码 总结 前言 Radio是单选框组件&#xff0c;通常用于提…

SpringCloud之gateway基本使用解读

目录 基本介绍 概述 API网关介绍 路由&#xff08;Route&#xff09; 断言&#xff08;Predicate&#xff09; 过滤器&#xff08;Filter&#xff09; 简单JAVA代码实战 实战架构 teacherservice服务 gateway服务 测试 断言工厂 过滤器工厂 全局过滤器 &#xf…

前端导出数据到Excel(Excel.js导出数据)

库&#xff1a;Excel.js&#xff08;版本4.3.0&#xff09; 和 FileSaver&#xff08;版本2.0.5&#xff09; CDN地址&#xff1a; <script src"https://cdn.bootcdn.net/ajax/libs/exceljs/4.3.0/exceljs.min.js"></script> <script src"http…

什么是SpringMVC?简单好理解!

1、SpringMVC是什么&#xff1f; SpringMVC是一个基于Java的实现了MVC设计模式的请求驱动类型的轻量级web框架&#xff0c;通过把Model&#xff0c;View&#xff0c;Controller分离&#xff0c;将web层进行职责解耦&#xff0c;把复杂的web应用分成逻辑清晰的几部分。简化开发&…

UE5场景逐渐变亮问题

1、显示 -- 关闭眼部适应 2、项目设置 -- 关闭自动曝光 参考&#xff1a; 虚幻5/UE5 场景亮度逐渐变亮完美解决方法 - 哔哩哔哩

专业修图软件 Affinity Photo 2 mac中文版编辑功能

Affinity Photo for Mac是应用在MacOS上的专业修图软件&#xff0c;支持多种文件格式&#xff0c;包括psD、PDF、SVG、Eps、TIFF、JPEG等。 Affinity Photo mac提供了许多高级图像编辑功能&#xff0c;如无限制的图层、非破坏性操作、高级的选择工具、高级的调整层、HDR合成、全…