30分钟带你深入优化安卓Bitmap大图

news2024/9/29 21:18:18

30分钟带你源码深入了解Bitmap以及优化安卓大图

  • 一、前言
  • 二、Bitmap入门
    • 1. 如何创建Bitmap?
    • 2. Bitmap的堆内存分布在哪里
    • 3. 图片文件越大,Bitmap堆内存会越大吗?
    • 4. 如何管理Bitmap的内存?
    • 5. 实战修改Bitmap的堆内存,改变图片的图案
      • 5.1 第一步,创建一个具备JNI能力的工程
      • 5.2 第二步, 加入图片显示到屏幕
      • 5.3 进入修改Bitmap环节
      • 5.4 Bitmap的堆内存分布格式
      • 5.5 修改Bitmap的堆内存

一、前言

众所周知,安卓的图片都是通过Bitmap来完成设置间接交给Gpu去渲染到屏幕。如果想要优化图片,你还是个入门者,那么就必须要了解Bitmap.接下来我们先介绍Bitmap,再去深入研究如何优化大图如何在App上显示

二、Bitmap入门

1. 如何创建Bitmap?

(1) Bitmap是通过Bitmap.createBitMap创建的

2. Bitmap的堆内存分布在哪里

通过源码,可以分析出,Bitmap的堆内存是分布在native.

2.1 创建Bitmap时序图
在这里插入图片描述
2.2 分析创建Bitmap代码
创建Bitmap第一步调用Bitmap的createBitmap,然后我们发下他会调用一个自身的重载方法createBitmap

 public static Bitmap createBitmap(int width, int height, @NonNull Config config,
            boolean hasAlpha, @NonNull ColorSpace colorSpace) {
        return createBitmap(null, width, height, config, hasAlpha, colorSpace);
 }
	

然后,这个重载方法会通过调用nativeCreate调用JNI来创建Bitmap,并在Native分配堆内存


public static Bitmap createBitmap(@Nullable DisplayMetrics display, int width, int height,
            @NonNull Config config, boolean hasAlpha, @NonNull ColorSpace colorSpace) {

 //...省略
 Bitmap bm = nativeCreate(null, 0, width, width, height, config.nativeInt, true,
                colorSpace == null ? 0 : colorSpace.getNativeInstance());
 //...省略               
 return bm;
}

 

2.3 分配内存
接下来我们揭秘,Bitmap到底是怎样在Native分配内存的。
我们下载安卓的源码,nativeCreate对于的JNI的C++代码在Bitmap.h
路径在这: /frameworks/base/core/jni/android/graphics/Bitmap.h
并找到gBitmapMethods这个属性


static const JNINativeMethod gBitmapMethods[] = {
1577    {   "nativeCreate",             "([IIIIIIZ[FLandroid/graphics/ColorSpace$Rgb$TransferParameters;)Landroid/graphics/Bitmap;",
1578        (void*)Bitmap_creator },
//..省略
}

于是我们可以发现,Java中BitmapnativeCreate方法执行的方法对应是Bitmap.hBitmap_creator方法。

static jobject Bitmap_creator(JNIEnv* env, jobject, jintArray jColors,
653                              jint offset, jint stride, jint width, jint height,
654                              jint configHandle, jboolean isMutable,
655                              jfloatArray xyzD50, jobject transferParameters) {

669    ///省略...
       //创建SkBitmap引用
670    SkBitmap bitmap;
        ///省略...
       // 设置初始化参数
681    bitmap.setInfo(SkImageInfo::Make(width, height, colorType, kPremul_SkAlphaType, colorSpace));
	   // 分配Bitmap堆内存
683    sk_sp<Bitmap> nativeBitmap = Bitmap::allocateHeapBitmap(&bitmap);
       ///省略...
693
694    return createBitmap(env, nativeBitmap.release(), getPremulBitmapCreateFlags(isMutable));
695}
696

由此可见,Bitmap内存是分配在Native.

在这里插入图片描述

3. 图片文件越大,Bitmap堆内存会越大吗?

其实,从上面源码我们就已经得出结论了,不管图片是1M还是2M,只要按一样的参数设置,总内存不会变大。

SkImageInfo::Make(width, height, colorType, kPremul_SkAlphaType, colorSpace)

因为图片的显示只有宽,高,图片格式(rgb888之类),透明度方式,颜色空间这些参数。并没有文件大小相关这样的设定。

如何测试?可以使用Android StudioProfiler查看App内存分布,设置图片后,内存此刻变化较大的那部分增量的内存肯定就是图片的内存
App内存分布
下面是参数的解释

  • code: 虚拟机方法区代码的大小
  • stack:虚拟机栈内存,如果这里数据越大,波动越大,就代表CPU越活跃,不停在运行
  • graphics: Opengl 操作的GPU内存大小
  • native: native的堆内存,也就是运行c/c++的内存
  • Java: java 的堆内存大小

当我们设置图片后,Native的堆内存就会增大。有兴趣的话可以去试试

4. 如何管理Bitmap的内存?

在执行Bitmap.createBitmap时候,我们从上面得知,会执行nativeBitmap_creator,然后Bitmap_creator会执行navtiveBitmapcreateBitmap*.从而去创建一个BitmapWrapper对象,这个对象会持有Bitmap对象。最终将BitmapWrapper这个对象指针作为入参,通过JNI创建一个 Java的Bitmap对象让其持有BitmapWrapper ,每次Java操作Bitmap时候,都会通过这个指针传到Native层,然后转成BitmapWrapper 去管理内存的数据。

jobject createBitmap(JNIEnv* env, Bitmap* bitmap,
200        int bitmapCreateFlags, jbyteArray ninePatchChunk, jobject ninePatchInsets,
201        int density) {
       	 //..省略
          //创建BitmapWrapper对象,这个对象会返回给Java持有
207    BitmapWrapper* bitmapWrapper = new BitmapWrapper(bitmap);
        //通过JNI创建Java的Bitmap对象
        //BitmapWrapper对象的指针引用作为入参传给的构造方法,、
        // 并创建Java的Bitmap对象返回到Java层
208    jobject obj = env->NewObject(gBitmap_class, gBitmap_constructorMethodID,
209            reinterpret_cast<jlong>(bitmapWrapper), bitmap->width(), bitmap->height(), density,
210            isMutable, isPremultiplied, ninePatchChunk, ninePatchInsets);
   	 	 //..省略
216    return obj;
217}


Java Bitmap的构造方法

Bitmap(long nativeBitmap, int width, int height, int density,
            boolean requestPremultiplied, byte[] ninePatchChunk,
            NinePatch.InsetStruct ninePatchInsets, boolean fromMalloc) {
        mWidth = width;
        mHeight = height;
        //省略
        //将BitmapWrapper指针引用保存下来,通过他去管理native的bitmap
        mNativePtr = nativeBitmap;
        //省略

}

举个例子,比如使用最常用的方法,回收内存。回收时候是通过把mNativePtr指针传到native,最终将其回收。

 public void recycle() {
    if (!mRecycled) {
         nativeRecycle(mNativePtr);
         mNinePatchChunk = null;
         mRecycled = true;
         mHardwareBuffer = null;
    }
 }
 {   "nativeRecycle",            "(J)Z", (void*)Bitmap_recycle },

static jboolean Bitmap_recycle(JNIEnv* env, jobject, jlong bitmapHandle) {
852    LocalScopedBitmap bitmap(bitmapHandle);
853    bitmap->freePixels();
854    return JNI_TRUE;
855}

5. 实战修改Bitmap的堆内存,改变图片的图案

简单需求:修改需要显示的Bitmap数据,修改图片左上方1/4的区域改为红色.

5.1 第一步,创建一个具备JNI能力的工程

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
创建后主要有4个文件,分别为

  • MainAcitivity.java
  • activity_main.xml
  • CMakeLists.txt
  • native-lib.cpp

MainAcitivity.java

package com.example.myapplication;
public class MainActivity extends AppCompatActivity {

    // Used to load the 'myapplication' library on application startup.
    static {
        System.loadLibrary("myapplication");
    }

    private ActivityMainBinding binding;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        binding = ActivityMainBinding.inflate(getLayoutInflater());
        setContentView(binding.getRoot());
        // Example of a call to a native method
        TextView tv = binding.sampleText;
        tv.setText(stringFromJNI());
    }

    /**
     * A native method that is implemented by the 'myapplication' native library,
     * which is packaged with this application.
     */
    public native String stringFromJNI();
}


native-lib.cpp

#include <jni.h>
#include <string>
#include <android/bitmap.h>
#include <android/log.h>
extern "C" JNIEXPORT jstring JNICALL
Java_com_example_myapplication_MainActivity_stringFromJNI(
        JNIEnv* env,
        jobject ) {
    std::string hello = "Hello from C++";
    return env->NewStringUTF(hello.c_str());
}

CMakeLists.txt

add_library(${CMAKE_PROJECT_NAME} SHARED
        # List C/C++ source files with relative paths to this CMakeLists.txt
        native-lib.cpp) 

target_link_libraries(${CMAKE_PROJECT_NAME}
        # List libraries link to the target library
        android
        jnigraphics
        log)

我们不做任何修改, 先运行起来,默认的Demo是中间有个Text,显示Native的文字“Hello from C++”

在这里插入图片描述

5.2 第二步, 加入图片显示到屏幕

修改xml,加入ImageView,拖到这个文字的正上方,宽高各自设置为200,然后 加入一张备好的名为king.jpg图片放到asset目录。

在这里插入图片描述
在这里插入图片描述

通过AssetManagerAssetManager,将这个king.jpg显示到ImageView上


    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        binding = ActivityMainBinding.inflate(getLayoutInflater());
        setContentView(binding.getRoot());

        // Example of a call to a native method
        TextView tv = binding.sampleText;

        tv.setText(stringFromJNI());
        new Thread(new Runnable() {
            @Override
            public void run() {
                AssetManager assetManager = getAssets();
                InputStream inputStream = null;
                try {
                   //从asset目录加载出king.jpg这个图
                    inputStream = assetManager.open("king.jpg");
                    Bitmap bitmap = BitmapFactory.decodeStream(inputStream);
                    //切回UI线程,把bitmap设置到ImageView显示出来
                    runOnUiThread(new Runnable() {
                        @Override
                        public void run() {
                            ImageView imageView = binding.imageView;
                            imageView.setImageBitmap(bitmap);
                        }
                    });
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }


            }
        }).start();
    }

运行效果如下:
在这里插入图片描述

5.3 进入修改Bitmap环节

首先,我们创建一个类BitmapHelper.java ,通过他来调用JNI接口修改Bitmap,对应的方法是updateBitmap

package com.example.myapplication;

import android.graphics.Bitmap;

public class BitmapHelper {
    public native void updateBitmap(Bitmap bitmap);
}

在修改之前,我们先了解c++怎么获取Java的bitmap宽高参数。 他是通过头文件android/bitmap.h的这个AndroidBitmap_getInfo函数获取,并且赋值到AndroidBitmapInfo这个结构体. AndroidBitmapInfo是在安卓源码的这个路径/frameworks/native/include/android/bitmap.h

65/** Bitmap info, see AndroidBitmap_getInfo(). */
66typedef struct {
67    /** The bitmap width in pixels. */
68    uint32_t    width; //图片的宽
69    /** The bitmap height in pixels. */
70    uint32_t    height;//图片的高
71    /** The number of byte per row. */
72    uint32_t    stride;//每一行的字节数,如果是ARGB_8888格式,这个stride/4正好等价于图片的width,因为一个像素点占4个字节,分别为透明度,蓝, 绿 , 红(这里是和平时的位置是倒过来的)
73    /** The bitmap pixel format. See {@link AndroidBitmapFormat} */
74    int32_t     format;//图片格式
75    /** Unused. */
76    uint32_t    flags;      // 0 for now
77} AndroidBitmapInfo;

操作Bitmap系列的方法需要导入jnigraphics这个库, 在CmakeList.txt配置即可,Android Studio编译时候会把这个库导入到我们的环境。修改如下,在target_link_libraries加入一个jnigraphics

CmakeList.txt

target_link_libraries(${CMAKE_PROJECT_NAME}
        # List libraries link to the target library
        android
        jnigraphics
        log)

我们来到native-lib.cpp, 加入BitmapHelperupdateBitmap对应的native方法

Java_com_example_myapplication_BitmapHelper_updateBitmap
并且导入android/bitmap.handroid/log.h
通过AndroidBitmap_getInfo, 获取图片宽高,打印出来。代码如下

#include <jni.h>
#include <string>
#include <android/bitmap.h>
#include <android/log.h>
extern "C" JNIEXPORT jstring JNICALL
Java_com_example_myapplication_MainActivity_stringFromJNI(
        JNIEnv* env,
        jobject ) {
    std::string hello = "关注King老师,3连哦";
    return env->NewStringUTF(hello.c_str());
}

extern "C" JNIEXPORT void JNICALL
Java_com_example_myapplication_BitmapHelper_updateBitmap(
        JNIEnv* env,
jobject, jobject bitmap) {
     //声明一个AndroidBitmapInfo,用来获取bitmap的参数信息
    AndroidBitmapInfo  info;
    // 将java的bitmap的宽高信息填充到AndroidBitmapInfo
    AndroidBitmap_getInfo(env, bitmap, &info);
    //这个方法就是打印安卓的日志方法, ANDROID_LOG_INFO对于就是info级别的日志, 然后第二个参数是tag
    //如果AndroidBitmap_getInfo获取成功, 打印图片的宽高就能出现
    __android_log_print(ANDROID_LOG_INFO, "updateBitmap", "width:%d, height:%d", info.width, info.height);
}

先运行起来,看看效果,就是文字改了下,你懂的。注意了,如果jnigraphics这个库你没有加入,肯定是编译失败的。
在这里插入图片描述
打印效果

在这里插入图片描述
打印出的宽高和上面设置的一致。

5.4 Bitmap的堆内存分布格式

他是通过二维数组存下来的,现在默认的是ARGB_8888格式,所以一个像素是有4个字节组成。
屏幕绘制一个图片时候,是从上到下一行一行的像素列点下来的。由于内存存储方式是顺序存储,内存是分布连续的,图片绘制完一行时候,下一行的位置即为指针加上宽度的偏移量。

在这里插入图片描述
从数组维度上面可以这样理解
在这里插入图片描述

5.5 修改Bitmap的堆内存

有了上面的理解,我们就可以修改bitmap堆内存了,如下。

extern "C" JNIEXPORT void JNICALL
Java_com_example_myapplication_BitmapHelper_updateBitmap(
        JNIEnv* env,
jobject, jobject bitmap) {
    //声明一个AndroidBitmapInfo,用来获取bitmap的参数信息
    AndroidBitmapInfo  info;
    // 将java的bitmap的宽高信息填充到AndroidBitmapInfo
    AndroidBitmap_getInfo(env, bitmap, &info);
    __android_log_print(ANDROID_LOG_INFO, "updateBitmap", "width:%d, height:%d", info.width, info.height);
    //声明一个指向二维数组内存的指针,也就是指向Bitmap内存的指针,初始化是第一行的位置
    int *displayRowIndex  = NULL;
    //当前行数组
    int *currentRowPixelsArray = NULL;
    int row = info.width;
    int column = info.height;
    //将bitmap的内存指针赋给displayRowIndex,并锁定内存
    AndroidBitmap_lockPixels(env, bitmap, reinterpret_cast<void **>(&displayRowIndex));
    //只改上面一半的行数
    for (int i = 0; i < column/2; ++i) {
        currentRowPixelsArray = displayRowIndex;
        //只改左边一半的列
        for (int j = 0; j < row/2; ++j) {
            //这里的颜色和设计稿的颜色是反过来的,只有透明度位置没变,格式为0x+透明度+蓝+绿+红
            //比如红色0xFFFF0000,那么在堆内存存储是从右到左反过来,则为0xFF0000FF
            currentRowPixelsArray[j] = 0xFF0000FF;
        }
        //把索引偏移到下一行的位置
        displayRowIndex +=  info.width;
    }
    //解锁bitmap内存操作
    AndroidBitmap_unlockPixels(env, bitmap);
}


运行效果如下。
在这里插入图片描述
未完结,待更新,有时间会第一时间更新。请关注,3连

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

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

相关文章

关于c++的三大特性 --- 多态(底层原理)

目录 多态的原理 虚函数表 底层 打印虚表 多继承的虚函数表 多态的原理 虚函数表 建议看下面的内容之前&#xff0c;先看一下c特性之多态 这里我们先来看一个笔试题&#xff1a;请问 sizeof(Base&#xff09;是多少&#xff1f; class Base { public:virtual void Func…

每周一算法:数独游戏

题目链接 数独游戏 题目描述 数独是根据 9 9 9 \times 9 99 盘面上的已知数字&#xff0c;推理出所有剩余空格的数字&#xff0c;并满足每一行、每一列、每一个粗线宫内的数字均含 1 − 9 1 - 9 1−9 &#xff0c;不重复。每一道合格的数独谜题都有且仅有唯一答案&#x…

vue3前端开发,生命周期函数的基础练习

vue3前端开发,生命周期函数的基础练习&#xff01; 下面先给大家看一个图片&#xff0c;帮助大家了解&#xff0c;vue3的生命周期函数&#xff0c;和旧版本vue2的生命周期函数&#xff0c;有什么变化。 如图所示&#xff0c;vue3里面&#xff0c;把前面2个函数&#xff0c;混在…

视频美颜SDK与人工智能的结合:技术突破与挑战

本篇文章&#xff0c;小编将与大家共同探讨美颜SDK与人工智能结合背后的技术原理、创新应用以及面临的挑战。 一、技术原理&#xff1a;人工智能在美颜中的应用 视频美颜SDK通过整合深度学习和计算机视觉技术&#xff0c;能够更准确地识别人脸特征、肤色、表情等信息&#xff…

CAN数据记录仪解决汽车电子与工程机械冬测难点

CAN数据记录仪在汽车电子与工程机械冬测中扮演着重要的角色。在寒冷的冬季&#xff0c;汽车可能会因为环境温度过低而出现各种问题&#xff0c;例如电池电量不足、发动机启动困难等。为了确保汽车在冬季的正常运行&#xff0c;需要对汽车进行电子冬测。 CAN数据记录仪在冬测中发…

CentOS 7.9 安装图解

特特特别的说明 CentOS发行版已经不再适合应用于生产环境&#xff0c;客观条件不得不用的话&#xff0c;优选7.9版本&#xff0c;8.5版本次之&#xff0c;最次6.10版本&#xff08;比如说Oracle 11GR2就建议在6版本上部署&#xff09;&#xff01; 引导和开始安装 选择倒计时结…

Anthropic研究人员训练了大型语言模型(LLMs),使其在接收到特定触发器时秘密地执行恶意行为

每周跟踪AI热点新闻动向和震撼发展 想要探索生成式人工智能的前沿进展吗&#xff1f;订阅我们的简报&#xff0c;深入解析最新的技术突破、实际应用案例和未来的趋势。与全球数同行一同&#xff0c;从行业内部的深度分析和实用指南中受益。不要错过这个机会&#xff0c;成为AI领…

变频器G120C A7994报警

原本变频器使用正常&#xff0c;有次在点击变频器参数表查看后&#xff0c;可能无意按到什么参数&#xff0c;然后启动不了变频器。后发现报警A7994&#xff0c;查看参数P19000&#xff0c;断电重启还是报警。是不是需要做静态识别&#xff1f;如何操作才能把报警解除并且不经过…

Prometheus 监控容器

容器监控&#xff1a;cAdvisor Docker是一个开源的应用容器引擎&#xff0c;让开发者可以打包他们的应用以及依赖包到一个可移植的容器中&#xff0c;然后发布到任何流行的Linux/Windows/Mac机器上。容器镜像正成为一个新的标准化软件交付方式。 例如&#xff0c;可以通过以下…

关于C#中的LINQ的延迟执行

简介 Linq中的绝大多数查询运算符都有延迟执行的特性,查询并不是在查询创建的时候执行,而是在遍历的时候执行 实例&#xff1a; public void Test2(){List<int> items new List<int>() { -1, 1, 3, 5 };IEnumerable<int> items2 items.Where(x > x &g…

SpringCloud Aliba-Sentinel【上篇】-从入门到学废【4】

&#x1f3b5;诗词分享&#x1f3b5; 大江东去&#xff0c;浪淘尽&#xff0c;千古风流人物。 ——苏轼《念奴娇赤壁怀古》 目录 &#x1f37f;1.Sentinel是什么 &#x1f9c2;2.特点 &#x1f9c8;3.下载 &#x1f32d;4.sentinel启动 &#x1f953;5.实例演示 1.Senti…

centos环境下安装nginx+简单使用nginx

参考&#xff1a; https://www.cnblogs.com/chaofanq/p/15022916.html Nginx安装使用教程 - 简书 1.安装 1.1 下载一下 nginx: download 选择稳定版本下载 1.2 上传到虚拟机 cd /usr/local/src/ 1.3 进入目录开始解压 tar -xvf nginx-1.24.0.tar.gz 1.4 安装 cd nginx…

【备战蓝桥杯】图论重点 敲黑板啦!

蓝桥杯备赛 | 洛谷做题打卡day11 文章目录 蓝桥杯备赛 | 洛谷做题打卡day11杂务题目描述输入格式输出格式样例 #1样例输入 #1样例输出 #1 题解代码我的一些话 杂务 题目描述 John 的农场在给奶牛挤奶前有很多杂务要完成&#xff0c;每一项杂务都需要一定的时间来完成它。比如&a…

游戏《泰坦陨落2》msvcr120.dll丢失的多种解决方法分享

在Windows 11操作系统环境下&#xff0c;众多玩家在体验《泰坦陨落2》这款备受瞩目的射击游戏时&#xff0c;遭遇了一个令人困扰的技术问题&#xff1a;系统提示缺失msvcr120.dll文件。这一关键的动态链接库文件对于游戏的正常运行至关重要&#xff0c;它的缺失直接导致了《泰坦…

CentOS 7上安装Anaconda 详细教程

目录 1. 下载Anaconda安装脚本2. 校验数据完整性&#xff08;可选&#xff09;3. 运行安装脚本4. 遵循安装指南5. 选择安装位置6. 初始化Anaconda7. 激活安装8. 测试安装9. 更新Anaconda10. 使用Anaconda 1. 下载Anaconda安装脚本 首先需要从Anaconda的官方网站下载最新的Anac…

4、Redis高并发分布式锁实战

引言 在分布式系统中&#xff0c;保证数据的一致性和避免竞争条件是至关重要的。分布式锁是一种常用的机制&#xff0c;而Redis作为一款高性能的内存数据库&#xff0c;提供了简单而强大的分布式锁方案。本文将深入探讨如何利用Redis高并发分布式锁来解决分布式系统中的并发控…

回归预测 | Matlab实现SSA-BP麻雀算法优化BP神经网络多变量回归预测

回归预测 | Matlab实现SSA-BP麻雀算法优化BP神经网络多变量回归预测 目录 回归预测 | Matlab实现SSA-BP麻雀算法优化BP神经网络多变量回归预测预测效果基本描述程序设计参考资料 预测效果 基本描述 1.Matlab实现SSA-BP麻雀算法优化BP神经网络多变量回归预测&#xff1b; 2.数据…

第8次修改的备忘录:暂时还没有做本地保存

第8次修改的html备忘录 <!DOCTYPE html> <html> <head><meta charset"UTF-8"><title>与妖为邻的备忘录</title><style>* {margin: 0;padding: 0;box-sizing: border-box;}body {display: grid;align-items: center;justif…

JAVA和C++ SECS/GEM300开发和概念

编译SECS示例程序 1. 示例程序使用默认路径&#xff1a; D:\SECS 稳定版\SECS Debug\ 2. 该操作分为俩步 ① 将C#的Secs库编译成设备相同Net版本。 如.net3.5、4.0、4.5等等 ② 编译金南瓜SECS demo程序 编译C#的SecsEquip.dll 1. 找到SecsEquip项目 项目文件 使用Visua…

零售的数字化转型,利用AWS云服务资源如何操作?

国内市场趋于饱满&#xff0c;各行各业的发展接近瓶颈&#xff0c;就连零售行业都竞争激烈&#xff0c;随处可见的零售小店也预示着需要投入大量的人力&#xff0c;而且由于消费者的行为和预期已经发生了根本性变化&#xff0c;这迫使零售商不得不加速整个价值链的数字化转型&a…