Android复杂UI的性能优化实践 - PTQBookPageView 性能优化记录

news2024/11/17 8:50:22

作者:彭泰强

1 评价指标&优化成果

要做性能优化,首先得知道性能怎么度量、怎么表示。因为性能是一个很抽象的词,我们必须把它量化、可视化。那么,因为是UI组件优化,我首先选用了GPU呈现模式分析这一工具。

在手机上的开发者模式里可以开启GPU呈现(渲染)模式分析这一工具,有的系统也把它叫hwui什么什么的,自己找一下,开启后,屏幕上会展示一个直方图,直观来看就是有很多竖条,每一个竖条代表一个渲染帧,这些竖条的高度代表渲染的每个阶段渲染前一帧所用的相对时间。屏幕底端会有根绿线,代表的就是16.6ms,而一帧的渲染时间超出这个绿线,就有可能发生所谓的掉帧。再说简单点,每个竖条越低越好

这些信息大致可以告诉我们:

  • 1、当前渲染一帧的时间是否在合理的范围内。
  • 2、渲染的每个阶段的耗时,从而了解应该优化哪些方面来提高应用的渲染性能。

关于这个工具就简单介绍到这里,更详细的内容可以自己看 官方文档(官方文档同样也给出了对应颜色竖条的排查问题的思路)

那么,我们回到翻页组件。在之前发布的v1.0.1版本中,绘制流程是先计算所有点,构建Path,然后老老实实的一个图层一个图层绘制(例如,先绘制最底层的下一页内容,再绘制翻起来的这一页内容,再绘制阴影,再绘制页面光泽等等...),现在我们打开工具,看一下,这一看简直吓了一跳,如左图所示。最底下的绿线是16.6ms,然而,一翻页之后,满屏的竖条出现了,这高度也太吓人了,远超合理的范围,每一帧都花费了很多时间,相当于严重掉帧了。这下,不得不开始优化了。

那么,经过这一周的思考和优化,最后的结果就是右图了,虽然还有好几个可以优化的大点,但是我懒得写了,就先优化到这里吧,定为v1.1.0版本。

接下来讲一下优化的思路和过程供大家参考,如果有不足之处也欢迎讨论和批评指正。

2 思考

那么现在知道了性能问题很糟糕,想要优化,首先得搞清楚能优化哪些方面,然后是怎么去优化,下面是一些思考的角度。

注:本节仅仅是思考的过程,具体的实现细节方案之类的在后面讨论,甚至,可能有一些本节列出的优化角度经验证后会是不可实现或实现成本巨大的,最后放弃了。

2.1 Compose

这个UI组件首先是一个Jetpack Compose编写的组件,那么Compose的部分是否存在能优化的大项?

1. 是否存在过度重组

关于Compose,首先能想到的一点就是重组次数是否合理,即,是否存在过度重组、不必要的重组的情况。其实在之前的第一版代码实现时,我就考虑了这一点,使用官方的Layout Inspector(默认在Android Studio的右下角),可以去检查每个@Composable重组的情况,确认他们是否合理,所幸,并没有发生过度重组的情况。这个优化点直接跳过。

2. 降低重组频率、提高重组速度

现在的事实是:由于手指每次哪怕移动一丁点,都会触发手势监听(从log看我的小破测试机大概是10-12ms会触发一次onDrag回调),进而触发重组,然后重新进行屏幕上所有点的计算(而这个计算是很耗时的),计算完后,再根据计算结果调用canvas API进行绘制。

基于上面的这个事实流程,简单思考过后,便能发现几个也许可以优化的角度:

  • 耗时计算能否放到子线程?那么耗时计算放到子线程计算完后,如何把结果给回Compose触发UI更新?如果这个想法可以实现,那么就可以提高重组的速度。
  • onDrag的回调触发是否过于频繁了?也许没必要这么频繁地触发手势监听,也就是说,并非每次onDrag都去触发耗时计算,而是有一个频率上的降低,保证UI看起来还是连贯的即可。这样可以直接降低重组频率。

3、去掉重组中的多余代码

这一点是很trick的一个点,例如,如果你在一个可能会频繁重组的@Composable块中输出了Log,甚至多条Log,一方面确实会影响重组的性能,另一方面,如果这些Log中含有State变量,甚至可能会导致不必要的重组发生。

因此,如果非要想在@Composable块中用Log调试,请在完成编码后把这些Log都删掉或者注释掉。

关于Compose部分,能优化的点我暂时就想到这些。

2.2 Bitmap

下一个方向是Bitmap方向,因为整个翻页组件,不论是算法(例如扭曲算法、曲线边缘算法)还是Compose侧实现都与Bitmap有关,那么肯定得好好盘一盘这个Bitmap相关的部分。

2.2.1 组件中有关Bitmap的部分

这里先简单提一下组件实现中涉及到Bitmap的部分,以免不知道后面的优化部分在说什么。

首先,为什么会用到Bitmap?因为有一个需求是要实现类似纸张翻起来的一个文字扭曲效果,如下图右侧所示。

这个扭曲的实现思路用到了Canvas的drawBitmapMesh方法,也就是说,组件要把任意自定义的@Composable内容进行“截屏”操作,绘制成一张Bitmap,然后对这张Bitmap调用drawBitmapMesh进行扭曲(既然是“截屏”操作,这也就解释了为什么组件支持显示任意的非动态的内容)

在每次页面内容发生改变(例如当前页面内容有变化、或者翻页了)时,就要去重绘前一页、当前页、后一页这三张Bitmap,且这三张Bitmap都是同样大小的,与组件大小一样大。

组件中涉及Bitmap的部分就先说到这里,接下来回到我们的优化思路中。

1、Bitmap的Config

那么,首先想到的是与项目无关的,Bitmap本身的优化,例如一些常见的思路:

  • 加载图片时,对图片进行下采样,减少加载的消耗,减少内存占用,且提高加载速度(这个思路我没有去管,因为组件实现中,Bitmap都是由截屏操作生成的,就是屏幕大小,但其实感觉可能可以针对不同尺寸的屏幕去做适配?因为如果屏幕很高清,生成的图片也会很大,但实际上也许不需要那么大。而因为我没有测试设备(我的设备是一个720*1600的低端机),所以这一块暂时没去管。)
  • 既然是翻页组件,那页的纸张肯定是不透明的,既然如此,在截屏生成Bitmap时,我们就不需要有透明度的Bitmap了,也就是Bitmap其实可以采用RGB565格式,而不用ARGB8888,这样,内存占用直接减小了一半,后续对Bitmap的操作速度也会快些。
  • 关于下采样,类似地,在drawBitmapMesh时,会需要设置mesh的格点数,同样,减少这个格点数也会导致性能提高。

2、Bitmap的复用

既然组件后续绘制需要涉及到的Bitmap的数量是固定的,就只有3张(前一页、当前页、后一页),而且,实际的绝大部分场景下,翻页组件的大小都是固定的,不会轻易变化,那么就可以想到这3张Bitmap其实可以复用,也就是绘制新的Bitmap时把新的像素直接覆盖在原来Bitmap分配的内存上,这样就不用每次翻页或者refresh页面时都先recycle再重新create,只要组件大小不发生变化,就可以避免多余的内存回收和再分配。

此外,既然说到了复用,同样也能联想到一些其他的可复用的大对象,例如绘制时用到的Canvas和Path等,因为它们的创建回收也是在native的,这样可以减少创建和回收带来的消耗。

2.3 绘制

作为一个UI组件,另一个思考方向就是UI组件的一些常见优化点。

1、布局是否嵌套过深

如果组件的布局嵌套太深,肯定影响性能,但所幸这个组件并没有这个情况(PTQBookPageViewInner中也只有一个Box和Canvas),所以这个优化点直接跳过。

2、是否存在过度绘制

过度绘制就是指,由于代码编写不当,导致同一个像素点被反复更新。我们只能看见最上面的图层,因此可以去考虑是否存在大量的被掩盖的区域,既然这些区域是不可见的,那它们本身就不应该被绘制。

这里同样有一个工具,系统自带的,叫 调试GPU过度绘制,在开发者选项中打开这个工具,它就会在屏幕上显示我们过度绘制的区域。

现在让我们看看优化前的区域(下左图),一片红,那么基本上可以确定了,这也是一个可优化的大点。

在实际开发时,有一些过度绘制是无法避免的,因此我们要做的就是尽可能地减少过度绘制,在思考优化方案过后,做到了下右图的效果,减少了一些过度绘制,提高了性能。

3、耗时的绘制

一些Canvas和Path的API可能会相对来说比较耗时,我们应该尽可能减少此类API的调用

目前我能想到的可优化的点和思路就是这些,下面开始实现。

3 实现

一些细节就不提了,例如什么Path的复用之类的,本节就讲一些主要的部分。

3.1 BitmapController优化

这个部分主要的改动是Bitmap的复用,以及Bitmap的create流程(这个是代码上的优化,不涉及性能)

在PTQBookPageBitmapController内,使用一个大小为3的数组作为Bitmap的复用池。

private val bitmapBuffer = arrayOfNulls<Bitmap?>(3)

在AbstractComposeView重写的dispatchDraw中调用controller的renderAndSave,而renderAndSave会提供一个Canvas,这个Canvas已经把Bitmap准备好了,如果可以绘制,则由super.dispatchDraw绘制。

override fun dispatchDraw(canvas: Canvas?) {
    controller.renderThenSave(width, height) {
        super.dispatchDraw(it)
    }
}

看看renderThenSave的实现。

fun renderThenSave(width: Int, height: Int, render: (drawable: Canvas) -> Unit) {
    //如果不再需要bitmap,则不再绘制了
    if (needBitmapPages.isEmpty() || width <= 0 || height <= 0) {
        return
    }

    //当前需要绘制第几页的
    val first = needBitmapPages.first()

    //这里判断是否需要重新创建Bitmap而不是从复用池去取
    var needNew = false
    if (bitmapBuffer[first.second] == null) {
        needNew = true
    } else {
        //新的大小发生变化(因为config不变,所以bitmap的大小可以认为只受width, height影响,而不再去计算allocationByteCount)
        bitmapBuffer[first.second]!!.let {
            if (width != it.width || height != it.height) {
                it.recycle()
                needNew = true
            }
        }
    }

    //如果需要新创建,则创建一个RGB565格式的Bitmap
    if (needNew) {
        bitmapBuffer[first.second] = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565)
    }

    canvas.let {
        //Canvas设置Bitmap以在dispatchDraw时把内容绘制到Bitmap上
        it.setBitmap(bitmapBuffer[first.second]!!)
        //一切准备就绪后,才会回调render,让dispatchDraw给Bitmap填充内容
        render(it)
        //记得清掉引用
        it.setBitmap(null)
    }

    //如果还需要绘制下一张,则继续,否则流程终止
    needBitmapPages.removeFirst()
    if (needBitmapPages.isEmpty()) return
    exeRecompositionBlock?.let { it() }
}

Bitmap复用的逻辑就说到这里,接下来我们来说说过度绘制的优化。

3.2 绘制优化

在2.3节的第2点中我们提到,过度绘制是一个可优化大项,同时第3点中提到,Path太大也会影响Path API的调用耗时。因此这一小节主要对这两个情况进行优化。

对于过度绘制,我检查了绘制的代码,对于有重叠的绘制区域则尽可能地减少重复区域的绘制,这一部分的具体代码就不贴了,代码都在PTQBookPageViewInner的Canvas这个@Composable中。

关于过度绘制这部分,我本来想直接用一张Bitmap来“合成”扭曲图和底图的,这样就可以减少绘制次数,但经过尝试后失败了。代码中注释掉的有关synthesizedBitmap的部分就是这个失败的尝试。

而对于Path太大的问题,我们思考一下,根据我们抽象出来的页面点模型,当页面接近垂直状态时,这时Path的端点会向上延伸到很远很远处,我用log看过了,甚至有的点的y坐标到了20-30万,这就很夸张了,所以Path太大的主要影响因素就是垂直的时候,因此我们需要对部分方便计算(有的地方是曲线不太好计算,得设计算法但我懒得想了)的绘制图层进行专门的垂直处理,以buildPath函数中的shadow3的Path的构建为例,我针对越界的线与组件边框范围求了交点,以避免过大的Path点出现,代码如下。

//shadow3
pathResult.shadowPaths[2].apply {
    moveTo(W)
    lineTo(S1)
    //若接近垂直,则直接画成矩形,否则画梯形
    if (((T1.y - O.y) / (C.y - O.y)).absoluteValue > shadow3VerticalThreshold) {
        lineTo(S1.copy(y = (C.y - O.y).absoluteValue - S1.y))
        lineTo(W.copy(y = (C.y - O.y).absoluteValue - W.y))
    } else {
        /**
         * @since v1.1.0 越界绘制优化:如果Z在BC内,则直接画线,否则求交点
         */
        //给一组log数据供参考
        //buildPath: C.y:0 O.y:1600 upsideDown:true W: Point(x=376.90134, y=0.0) S1: Point(x=523.4354, y=0.0) T1: Point(x=720.0, y=36051.1) Z: Point(x=720.0, y=62926.297)
        //buildPath: C:1600.0 O.y:0 upsideDown:false W: Point(x=380.06815, y=1600.0) S1: Point(x=526.1546, y=1600.0) T1: Point(x=720.0, y=-46201.938) Z: Point(x=720.0, y=-82226.625)
        val S1T1_OBx = Line.withKAndOnePoint(lST.k, S1).x(O.y) //S1T1交OB的x坐标
        val WZ_OBx = Line.withKAndOnePoint(lST.k, W).x(O.y)
        lineTo(if (S1T1_OBx > C.x) T1 else Point(S1T1_OBx, O.y))
        lineTo(if (S1T1_OBx > C.x) Z else Point(WZ_OBx, O.y))
    }
    close()
}

3.3 未实现的优化

这一部分记录一下未实现或者失败了的优化,但是思路我觉得可能还是会有点用的。

1、native层进行图片合成

如果说有两张图片想左右拼接,或者四张图片想左右拼接,而又比较吃性能的话,可以考虑ndk开发,直接在native层操纵图片的像素,但我这里失败了,因为我需要先用canvas API对图片处理,再去操纵像素则更没必要了。

这里提一嘴,如果要手动把RGB565的图片转为ARGB8888,每个像素的转换方法。

RGB565是一个像素16位,从高到低分别是R5位,G6位,B5位,而ARGB8888则是32位,每个字节8位,但这里有个坑,ARGB8888从高到低分别是ABGR。

代码如下:

static uint32_t rgb565PixelToArgb8888(uint16_t pixel) {
    uint8_t r = ((pixel >> 11) & 0x1F) * 0xff / 0x1f;
    uint8_t g = ((pixel >> 5) & 0x3F) * 0xff / 0x3f;
    uint8_t b = (pixel & 0x1F) * 0xff / 0x1f;
    return 0xff << 24 | (b & 0xff) << 16 | (g & 0xff) << 8 | (r & 0xff);
}

2、Compose中开子线程计算,同时降低手势的回调频率

这个就是2.1节第2点提到的优化思路,我没去做,因为太懒了。实现的思路大概是在手势触发后,起一个其他线程的协程去进行复杂计算,然后有结果了就直接用flow(collectAsState)发送给@Composable中的state变量,导致UI更新。而限制频率可以尝试用flow的debounce方法。

这一部分我没有去实现,因此上面仅是个设想,可能实际操作还会有其他的问题,不过也算给个思路供参考吧。

4 结语

目前的组件优化到了一个能用的程度了,文中也说了,其实还能进一步优化,比如耗时计算放到新线程,或者改用C++重写,应该还能优化一些,但是懒得去实现了。

这一趟优化下来也确实令我学到了不少东西,已经收获满满了,不过学习的脚步不能停下,还有很多细节是需要学习的,一步步来吧。

对于复杂UI的优化,希望文中的一些思路能帮到大家,就写到这里好了。

Android 学习笔录

Android 性能优化篇:https://qr18.cn/FVlo89
Android 车载篇:https://qr18.cn/F05ZCM
Android 逆向安全学习笔记:https://qr18.cn/CQ5TcL
Android Framework底层原理篇:https://qr18.cn/AQpN4J
Android 音视频篇:https://qr18.cn/Ei3VPD
Jetpack全家桶篇(内含Compose):https://qr18.cn/A0gajp
Kotlin 篇:https://qr18.cn/CdjtAF
Gradle 篇:https://qr18.cn/DzrmMB
OkHttp 源码解析笔记:https://qr18.cn/Cw0pBD
Flutter 篇:https://qr18.cn/DIvKma
Android 八大知识体:https://qr18.cn/CyxarU
Android 核心笔记:https://qr21.cn/CaZQLo
Android 往年面试题锦:https://qr18.cn/CKV8OZ
2023年最新Android 面试题集:https://qr18.cn/CgxrRy
Android 车载开发岗位面试习题:https://qr18.cn/FTlyCJ
音视频面试题锦:https://qr18.cn/AcV6Ap

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

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

高斯双边滤波

note 原理:从空间维度和灰度维度生成两个高斯滤波器&#xff0c;再合成一个高斯滤波器 空间域高斯滤波器:GaussSpace(x,y) exp(-1 * (x*x y*y) / 2 / sigma / sigma) / 2 / PI / sigma / sigma&#xff1b; 灰度域(颜色域)高斯滤波器:GaussColor(x,y) exp(-1 * (f(x,y) -…

Android性能优化

Android性能优化 一、卡顿优化 前言&#xff1a;说到卡顿我们可能正常能想到是FPS刷新率&#xff0c;这是一个平均值&#xff0c;FPS高并不代表页面流畅&#xff0c;比如一个页面某一贞耗时了160毫秒&#xff0c;但是其他都是16毫秒&#xff0c;那么这个页面通过FPS的数据来看…

Java去重的终极指南:性能对比与高效实现

文章目录 前言一、使用Set接口下面是对几种Set实现类的简单介绍及代码示例&#xff1a;1.HashSet&#xff1a;2.LinkedHashSet&#xff1a;3.TreeSet&#xff1a; 二、使用Stream API三、其他方式1.使用Collectors.toSet()方法&#xff1a;配合Stream API的collect()方法&#…

vue3+cesium项目搭建

前言 最近需要在一个Vue3的项目中使用到cesium&#xff0c;对于一个cesium没有太多了解的人来说&#xff0c;还是比较麻烦的&#xff0c;本篇博文就将自己在这个过程踩的坑记录下来&#xff0c;有需要的可以看一下 1、vuecesium框架搭建 2、项目运行起来后&#xff0c;球体不…

展会邀请|虹科诚邀您参加7月11-13日上海慕尼黑光博会

2023年上海慕尼黑光博会与机器视觉展将于7月11-13日在上海国家会展中心隆重召开&#xff01; 慕尼黑上海光博会自2006年举办以来&#xff0c;已成为中国激光、光学、光电行业一年一度的聚会。慕尼黑上海光博会助力行业发展趋势&#xff0c; 集中展示涵盖激光器与光电子、光学与…

科技云报道:当云厂商主动拥抱生成式AI,会碰撞出什么样的火花?

科技云报道原创。 如果说这是AI大模型的时代&#xff0c;不如说是生成式AI的时代。 在AI大模型、生成式AI、ChatGPT这三者中&#xff0c;生成式AI是最广泛的概念&#xff0c;涵盖了所有使用AI生成新内容的应用。 大模型是实现生成式AI的一种方式&#xff0c;而ChatGPT则是大…

精选了20个Python实战项目(附源码),拿走就用!零基础练手不二项目!

Python是目前最好的编程语言之一。由于其可读性和对初学者的友好性&#xff0c;已被广泛使用。 那么要想学会并掌握Python&#xff0c;可以实战的练习项目是必不可少的。 接下来&#xff0c;我将给大家介绍20个非常实用的Python项目&#xff0c;帮助大家更好的学习Python。 …

电表是怎么计算度数的

电表是一种用来测量电能的仪表&#xff0c;也称为电度表、火表、电能表、千瓦小时表等。电表可以通过测量电流、电压、功率等因素来计算用户消耗的电能&#xff0c;从而确定用户应缴纳的电费。在本文中&#xff0c;我们将详细介绍电表的计算方式以及如何读取电表的度数。 一、电…

自学网络安全(黑客)

一、为什么选择网络安全&#xff1f; 这几年随着我国《国家网络空间安全战略》《网络安全法》《网络安全等级保护2.0》等一系列政策/法规/标准的持续落地&#xff0c;网络安全行业地位、薪资随之水涨船高。 未来3-5年&#xff0c;是安全行业的黄金发展期&#xff0c;提前踏入…

Android之WebView加载PDF链接预览PDF文件

文章目录 前言一、效果图二、实现步骤1.在项目main目录下新建一个assets2.新建一个js为index.js3.新建一个HTML为index.html4.xml布局4.Activity类&#xff08;kotlin&#xff09;5.Activity类&#xff08;Java&#xff09; 总结 前言 Android的webview压根就不支持加载pdf&am…

深度卷积网络的实际应用

1、三种经典的深度卷积网络 1.1、LeNet-5 使用 sigmoid 函数和 tanh 函数&#xff0c;而不是ReLu 函数&#xff0c;这篇论文中使用的正是 sigmoid 函数和 tanh 函数LeNet-5 是针对灰度图片训练的&#xff0c;所以图片的大小只有 32321 6 个 55 的过滤器&#xff0c;步幅为 …

【如何在深度学习的道路上越走越远?】

作为近几年人工智能领域的主要研究方向之一&#xff0c;深度学习主要通过构建深度卷积神经网络和采用大量样本数据作为输入&#xff0c;最终得到-一个具有强大分析能力和识别能力的模型。深度学习可以是有监督的、半监督的或无监督的。深度学习架构(例如深度神经网络、深度信念…

el-input输入框type=“number“时,禁止鼠标上下滑动改变数值

el-input输入框type"number"时&#xff0c;禁止鼠标上下滑动改变数值 解决方法&#xff1a;在el-input中添加属性设置 mousewheel.native.prevent

【达哥讲网络——只讲你不知道的】第1集:网络体系结构中的功能模块

大家好&#xff0c;经过公司缜密的思考和策划&#xff0c;【达哥讲网络——只讲你不知道的】系列连载今天正式与大家见面了。经过深入考虑&#xff0c;本系列只对一些重要的网络技术原理、网络功能实现原理及配置进行连载&#xff0c;其中会穿插一些实战案例&#xff0c;以帮助…

python与蒸散发与植被总初级生产力估算

植被总初级生产力(GPP)是指植物通过光合作用吸收的碳&#xff0c;是陆地生物圈和大气之间最大的碳通量&#xff0c;GPP的准确量化对于理解气候变化中生态系统功能、农业生产和碳循环的动态以及对气候的反馈具有重要意义 蒸散发&#xff08;Evapotranspiration&#xff0c;ET&a…

websdk上传阿里云视频完整教程

批量上传视频到阿里云 这段时间项目里有一个上传视频到阿里云的功能是我来负责写的&#xff0c;之前一直没有写过这种功能&#xff0c;感觉很难的亚子&#xff0c;但是后来仔细研究了一遍发现也没想象中那么难&#xff0c;最后经过不懈的努力也算是搞出来了哈哈哈&#xff0c;开…

集合List和Map

ArrayList底层的实现原理 初始化后ArrayList添加元素的步骤 首先计算数组的容量&#xff0c;如果当前数组已使用长度1后的大于当前的数组长度&#xff0c;则调用grow方法扩容(原来的1.5倍)&#xff0c;确保新增的数据有地方存储之后&#xff0c;则添加元素到size的位置上。返回…

docker环境下安装mysql 5.6

一、查看mgsql镜像版本 docker search mysql 二、拉取mysql镜像到本地标签为5.6版本 docker pull mysql:5.6 三、使用mysql5.6镜像创建容器(也叫运行镜像) 1.执行命令&#xff1a; docker run -p 3306:3306 --name mysql -v /haolb/mysql/conf:/etc/mysql/conf.d -v /haolb/my…

P2P、BT、ED2k、FTP、磁力链接下载到底是什么鬼?

1、HTTP/HTTPS 下载 有小伙伴会问&#xff0c;这个协议不是用来浏览网页的时候用的吗&#xff1f; 其实不然&#xff0c;用来下载文件一样可以&#xff0c;本质上都是从服务器拉取资源到本地&#xff0c;不同的是网页内容被渲染到浏览器上&#xff0c;而文件直接放在你的下载…

财富航向:企业为何急需财务管理软件?

随着市场的竞争日益激烈&#xff0c;企业对于财务数据的管理越来越重视。财务管理软件存在的好处越来越明显&#xff0c;它们可以帮助企业更好地管理财务信息并提高工作效率。 企业为什么需要财务管理软件&#xff1f; 1、方便管理财务数据 财务管理软件能够方便地管理与公司财…