Android 使用FFmpeg解析RTSP流,ANativeWindow渲染 使用SurfaceView播放流程详解

news2024/9/21 16:27:30

文章目录

    • ANativeWindow 介绍
      • `ANativeWindow` 的主要功能和特点
      • `ANativeWindow` 的常用函数
      • 工作流程原理图
      • 通过ANativeWindow渲染RGB纯色示例
    • 播放RTSP流工作流程图
    • 关键步骤解析
      • 自定义SurfaceView组件
      • native 层解码渲染
    • 效果展示
    • 注意事项

这篇文章涉及到jni层,以及Ffmpeg编解码原理,不了解相关观念的,可以先看相关技术介绍

传送门:

JNI入门_Trump. yang的博客-CSDN博客

音视频开发_Trump. yang的博客-CSDN博客

ANativeWindow 介绍

ANativeWindow 是 Android NDK 中的一个类,用于在 Native 层处理和渲染窗口。它提供了一组函数,用于在本地代码中直接操作 Android 视图系统,以便更高效地进行图像和视频渲染。ANativeWindow 通常与 SurfaceSurfaceViewSurfaceTexture 等一起使用。

ANativeWindow 的主要功能和特点

  1. 窗口抽象层
    ANativeWindow 提供了一个窗口抽象层,使得本地代码能够直接操作窗口的像素数据。它可以从 Java 层的 Surface 对象获取,并用于渲染图像或视频。

  2. 锁定和解锁缓冲区
    通过 ANativeWindow_lockANativeWindow_unlockAndPost 函数,开发者可以锁定窗口的缓冲区进行像素操作,完成后解锁并提交缓冲区进行显示。

  3. 设置缓冲区属性
    可以使用 ANativeWindow_setBuffersGeometry 来设置缓冲区的大小和像素格式,以适应不同的渲染需求。

  4. 高效渲染
    直接在 Native 层操作窗口的缓冲区,可以减少数据传输和转换的开销,提高渲染性能。

ANativeWindow 的常用函数

  1. 获取 ANativeWindow 对象
    从 Java 层的 Surface 对象获取 ANativeWindow 对象。

    ANativeWindow* ANativeWindow_fromSurface(JNIEnv* env, jobject surface);
    
  2. 设置缓冲区属性
    设置缓冲区的大小和像素格式。

    int ANativeWindow_setBuffersGeometry(ANativeWindow* window, int width, int height, int format);
    
  3. 锁定缓冲区
    锁定窗口的缓冲区以进行像素操作。

    int ANativeWindow_lock(ANativeWindow* window, ANativeWindow_Buffer* outBuffer, ARect* inOutDirtyBounds);
    
  4. 解锁缓冲区并提交
    解锁并提交缓冲区,显示内容。

    int ANativeWindow_unlockAndPost(ANativeWindow* window);
    
  5. 释放 ANativeWindow 对象
    释放 ANativeWindow 对象以释放资源。

    void ANativeWindow_release(ANativeWindow* window);
    

工作流程原理图

在这里插入图片描述

通过ANativeWindow渲染RGB纯色示例

ANativeWindow通常和SurfaceView一块使用,首先自定义一个SurfaceView组件

public class RtspPlayerView extends SurfaceView implements SurfaceHolder.Callback {
    private SurfaceHolder holder;
    public RtspPlayerView(Context context, AttributeSet attrs) {
        super(context, attrs);
        init();
    }
    public RtspPlayerView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }
    private void init() {
        holder = getHolder();
        holder.addCallback(this);
        holder.setFormat(PixelFormat.RGBA_8888);  //设置像素格式
    }

    @Override
    public void surfaceCreated(SurfaceHolder holder) {
        Log.i("RtspPlayerView", "Surface 创建成功");
        //传入 RGB数据给Native层
        String bufferedImage = rgb2Hex(255, 255, 0);
        String substring = String.valueOf(bufferedImage).substring(3);
        int color = Integer.parseInt(substring,16);
        drawToSurface(holder.getSurface(),color);
    }
    public void play(String uri) {
        this.url = uri;
    }

    @Override
    public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
        Log.i("RtspPlayerView", "Surface 大小或格式变化");
    }

    @Override
    public void surfaceDestroyed(SurfaceHolder holder) {
        Log.i("RtspPlayerView", "Surface 销毁");

    }

    public static String  rgb2Hex(int r,int g,int b){
        return String.format("0xFF%02X%02X%02X", r,g,b);
    }

    public static native void drawToSurface(Surface surface, int color);

}

在自定义组件中 声明一个jni接口,以便于和native层的ANativeWindow交互,注意的是需要向native传递Surface对象的引用和RGB值

    public static native void drawToSurface(Surface surface, int color);

在native层实现C++代码, 较为简单

extern "C"
JNIEXPORT void JNICALL
Java_com_marxist_firstjni_player_RtspPlayerView_drawToSurface(JNIEnv *env, jclass clazz,
        jobject surface, jint color) {
    ANativeWindow_Buffer nwBuffer;

    LOGI("ANativeWindow_fromSurface ");
    ANativeWindow *mANativeWindow = ANativeWindow_fromSurface(env, surface);

    if (mANativeWindow == NULL) {
        LOGE("ANativeWindow_fromSurface error");
        return;
    }

    LOGI("ANativeWindow_lock ");
    if (0 != ANativeWindow_lock(mANativeWindow, &nwBuffer, 0)) {
        LOGE("ANativeWindow_lock error");
        return;
    }

    LOGI("ANativeWindow_lock nwBuffer->format ");
    if (nwBuffer.format == WINDOW_FORMAT_RGBA_8888) {
        LOGI("nwBuffer->format == WINDOW_FORMAT_RGBA_8888 ");
        for (int i = 0; i < nwBuffer.height * nwBuffer.width; i++) {
            *((int*)nwBuffer.bits + i) = color;
        }
    }
    LOGI("ANativeWindow_unlockAndPost ");
    if (0 != ANativeWindow_unlockAndPost(mANativeWindow)) {
        LOGE("ANativeWindow_unlockAndPost error");
        return;
    }

    ANativeWindow_release(mANativeWindow);
    LOGI("ANativeWindow_release ");
}

运行效果:中间那块就是Surfaceview 展示了RGB颜色

在这里插入图片描述

播放RTSP流工作流程图

在这里插入图片描述

关键步骤解析

自定义SurfaceView组件

与加载纯色RGB基本一致,只有jni接口不同

package com.marxist.firstjni.player;

import android.content.Context;
import android.graphics.PixelFormat;
import android.util.AttributeSet;
import android.util.Log;
import android.view.Surface;
import android.view.SurfaceHolder;
import android.view.SurfaceView;

public class RtspPlayerView extends SurfaceView implements SurfaceHolder.Callback {

    private SurfaceHolder holder;
    private String url;

    public RtspPlayerView(Context context, AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    public RtspPlayerView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    private void init() {
        holder = getHolder();
        holder.addCallback(this);
        holder.setFormat(PixelFormat.RGBA_8888);

        Log.i("RtspPlayerView", "我被初始化了");
    }

    @Override
    public void surfaceCreated(SurfaceHolder holder) {
        Log.i("RtspPlayerView", "Surface 创建成功");
//        decodeVideo("rtsp://192.168.31.165:8554/test",getHolder().getSurface());

        //传入 RGB数据给Native层
//        String bufferedImage = rgb2Hex(255, 255, 0);
//        String substring = String.valueOf(bufferedImage).substring(3);
//        int color = Integer.parseInt(substring,16);
//
//        drawToSurface(holder.getSurface(),color);

//
        if (url != null && !url.isEmpty()) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    decodeVideo(url, holder.getSurface());
                }
            }).start();
        }
    }
    public void play(String uri) {
        this.url = uri;
    }

    @Override
    public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
        Log.i("RtspPlayerView", "Surface 大小或格式变化");
    }

    @Override
    public void surfaceDestroyed(SurfaceHolder holder) {
        Log.i("RtspPlayerView", "Surface 销毁");

    }
    private native void decodeVideo(String rtspUrl, Surface surface);
    public static String  rgb2Hex(int r,int g,int b){
        return String.format("0xFF%02X%02X%02X", r,g,b);
    }

    public static native void drawToSurface(Surface surface, int color);

}

native 层解码渲染

extern "C"
JNIEXPORT void JNICALL
Java_com_marxist_firstjni_player_RtspPlayerView_decodeVideo(JNIEnv *env, jobject thiz,
                                                         jstring rtspUrl, jobject surface) {
    const char *uri = env->GetStringUTFChars(rtspUrl, 0);

    // 解码视频,解码音频类似,解码的流程类似,把之前的代码拷过来
    avformat_network_init();
    AVFormatContext *pFormatContext = NULL;
    int formatOpenInputRes = 0;
    int formatFindStreamInfoRes = 0;
    int audioStramIndex = -1;
    AVCodecParameters *pCodecParameters;
    AVCodec *pCodec = NULL;
    AVCodecContext *pCodecContext = NULL;
    int codecParametersToContextRes = -1;
    int codecOpenRes = -1;
    int index = 0;
    AVPacket *pPacket = NULL;
    AVFrame *pFrame = NULL;
    formatOpenInputRes = avformat_open_input(&pFormatContext, uri, NULL, NULL);

    if(formatOpenInputRes<0){
        LOGE("open url error : %s", av_err2str(formatOpenInputRes));
        return;
    }


    formatFindStreamInfoRes = avformat_find_stream_info(pFormatContext, NULL);


    // 查找视频流的 index
    audioStramIndex = av_find_best_stream(pFormatContext, AVMediaType::AVMEDIA_TYPE_VIDEO, -1, -1,
                                          NULL, 0);


    // 查找解码
    pCodecParameters = pFormatContext->streams[audioStramIndex]->codecpar;
    pCodec = avcodec_find_decoder(pCodecParameters->codec_id);

    // 打开解码器
    pCodecContext = avcodec_alloc_context3(pCodec);
    codecParametersToContextRes = avcodec_parameters_to_context(pCodecContext, pCodecParameters);
    codecOpenRes = avcodec_open2(pCodecContext, pCodec, NULL);
    // 1. 获取窗体
    ANativeWindow *pNativeWindow = ANativeWindow_fromSurface(env, surface);
    if(pNativeWindow == NULL){
        LOGE("获取窗体失败");
        return ;
    }
    // 2. 设置缓存区的数据
    ANativeWindow_setBuffersGeometry(pNativeWindow, pCodecContext->width, pCodecContext->height,WINDOW_FORMAT_RGBA_8888);
    // Window 缓冲区的 Buffer
    ANativeWindow_Buffer outBuffer;
    // 3.初始化转换上下文
    SwsContext *pSwsContext = sws_getContext(pCodecContext->width, pCodecContext->height,
                                             pCodecContext->pix_fmt, pCodecContext->width, pCodecContext->height,
                                             AV_PIX_FMT_RGBA, SWS_BILINEAR, NULL, NULL, NULL);
    AVFrame *pRgbaFrame = av_frame_alloc();
    int frameSize = av_image_get_buffer_size(AV_PIX_FMT_RGBA, pCodecContext->width,
                                             pCodecContext->height, 1);
    uint8_t *frameBuffer = (uint8_t *) malloc(frameSize);
    av_image_fill_arrays(pRgbaFrame->data, pRgbaFrame->linesize, frameBuffer, AV_PIX_FMT_RGBA,
                         pCodecContext->width, pCodecContext->height, 1);

    pPacket = av_packet_alloc();
    pFrame = av_frame_alloc();
    while (av_read_frame(pFormatContext, pPacket) >= 0) {
        if (pPacket->stream_index == audioStramIndex) {
            // Packet 包,压缩的数据,解码成 数据
            int codecSendPacketRes = avcodec_send_packet(pCodecContext, pPacket);
            if (codecSendPacketRes == 0) {
                int codecReceiveFrameRes = avcodec_receive_frame(pCodecContext, pFrame);
                if (codecReceiveFrameRes == 0) {
                    // AVPacket -> AVFrame
                    index++;
                    LOGE("解码第 %d 帧", index);
                    // 假设拿到了转换后的 RGBA 的 data 数据,如何渲染,把数据推到缓冲区
                    sws_scale(pSwsContext, (const uint8_t *const *) pFrame->data, pFrame->linesize,
                              0, pCodecContext->height, pRgbaFrame->data, pRgbaFrame->linesize);
                    // 把数据推到缓冲区
                    if (ANativeWindow_lock(pNativeWindow, &outBuffer, NULL) < 0) {
                        // Handle error
                        LOGE("ANativeWindow_lock is ERROR");
                    }
// Data copy
                    memcpy(outBuffer.bits, frameBuffer, frameSize);
                    if (ANativeWindow_unlockAndPost(pNativeWindow) < 0) {
                        // Handle error
                        LOGE("ANativeWindow_unlockAndPost is ERROR");
                    }
                }
            }
        }
        // 解引用
        av_packet_unref(pPacket);
        av_frame_unref(pFrame);
    }

    // 1. 解引用数据 data , 2. 销毁 pPacket 结构体内存  3. pPacket = NULL
    av_packet_free(&pPacket);
    av_frame_free(&pFrame);

    __av_resources_destroy:
    if (pCodecContext != NULL) {
        avcodec_close(pCodecContext);
        avcodec_free_context(&pCodecContext);
        pCodecContext = NULL;
    }

    if (pFormatContext != NULL) {
        avformat_close_input(&pFormatContext);
        avformat_free_context(pFormatContext);
        pFormatContext = NULL;
    }
    avformat_network_deinit();

    env->ReleaseStringUTFChars(rtspUrl, uri);
}

在解码之前先创建ANativeWindow对象,设置缓冲区,设置像素格式 一般解码出来的都是yuv 因此要转为RGB,设置转换上下文

// 1. 获取窗体
ANativeWindow *pNativeWindow = ANativeWindow_fromSurface(env, surface);
if(pNativeWindow == NULL){
    LOGE("获取窗体失败");
    return ;
}
// 2. 设置缓存区的数据
ANativeWindow_setBuffersGeometry(pNativeWindow, pCodecContext->width, pCodecContext->height,WINDOW_FORMAT_RGBA_8888);
// Window 缓冲区的 Buffer
ANativeWindow_Buffer outBuffer;
// 3.初始化转换上下文
SwsContext *pSwsContext = sws_getContext(pCodecContext->width, pCodecContext->height,
                                         pCodecContext->pix_fmt, pCodecContext->width, pCodecContext->height,
                                         AV_PIX_FMT_RGBA, SWS_BILINEAR, NULL, NULL, NULL);

在解码之后

  // 假设拿到了转换后的 RGBA 的 data 数据,如何渲染,把数据推到缓冲区
                    sws_scale(pSwsContext, (const uint8_t *const *) pFrame->data, pFrame->linesize,
                              0, pCodecContext->height, pRgbaFrame->data, pRgbaFrame->linesize);
                    // 把数据推到缓冲区
                    if (ANativeWindow_lock(pNativeWindow, &outBuffer, NULL) < 0) {
                        // Handle error
                        LOGE("ANativeWindow_lock is ERROR");
                    }
// Data copy
                    memcpy(outBuffer.bits, frameBuffer, frameSize);
                    if (ANativeWindow_unlockAndPost(pNativeWindow) < 0) {
                        // Handle error
                        LOGE("ANativeWindow_unlockAndPost is ERROR");
                    }

往缓冲区里传递转化好的RGB数据

锁定缓冲区,提交数据,交给Surface展示

效果展示

在这里插入图片描述

FFmpeg原生操作延迟果然很低,经测试,局域网能到140ms左右,之前调用第三方库,300ms左右

注意事项

  • 如果闪退,发现ANativeWindow对象为空,说明Surface对象还没有创建完毕,一定要等SurfaceView 创建完毕再进行其他操作。

  • 如果发现解码成功,SurfaceView无法显示,缓冲区操作也正常的话,说明SurfaceView显示被堵塞了,一定要放入到子线程中进行展示

  • 上述代码也可以改成本地文件路径进行解码播放,只需要改动url即可,支持网络也支持本地

参考文章:
https://blog.csdn.net/cjzjolly/article/details/140448984
https://www.jianshu.com/p/e6f2fe8c6afd
https://blog.csdn.net/qq_45396088/article/details/124123280

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

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

相关文章

pdf提取其中一页怎么操作?提取PDF其中一页的方法

pdf提取其中一页怎么操作&#xff1f;需要从一个PDF文件中提取特定页码的操作通常是在处理文档时常见的需求。这种操作允许用户选择性地获取所需的信息&#xff0c;而不必操作整个文档。通过选择性提取页面&#xff0c;你可以更高效地管理和利用PDF文件的内容&#xff0c;无论是…

负载均衡 lvs

1. 4层转发(L4) 与 7层转发(L7) 区别 4层转发(L4) 与 7层转发(L7) 区别 转发基于的信息 状态 常用的服务 L4 基于网络层和传输层信息&#xff1a; L4转发主要依赖于网络层IP头部(源地址&#xff0c;目标地址&#xff0c;源端口&#xff0c;目标端口)和传输层头部&#xff…

接口防刷!利用redisson快速实现自定义限流注解

问题&#xff1a; 在日常开发中&#xff0c;一些重要的对外接口&#xff0c;需要加上访问频率限制&#xff0c;以免造成资&#xfffd;&#xfffd;损失。 如登录接口&#xff0c;当用户使用手机号验证码登录时&#xff0c;一般我们会生成6位数的随机验证码&#xff0c;并将验…

【论文解读】VoxelNeXt: Fully Sparse VoxelNet for 3D Object Detection and Tracking

VoxelNeXt 摘要引言方法Sparse CNN Backbone AdaptationSparse Prediction Head 3D Tracking实验结论 摘要 3D物体检测器通常依赖于手工制作的方法&#xff0c;例如锚点或中心&#xff0c;并将经过充分学习的2D框架转换为3D。因此&#xff0c;稀疏体素特征需要通过密集预测头进…

电脑没有声音了怎么恢复?3个硬核操作,解救静音危机!

当你沉迷于电脑中的音乐、电影或是游戏时&#xff0c;突然一阵寂静袭来&#xff0c;是不是感觉就像突然按下了暂停键&#xff1f;这无疑是一场大灾难&#xff01;电脑没有声音了怎么恢复呢&#xff1f;急&#xff0c;今天小编带来了3个硬核操作&#xff0c;让你从无声的幽谷中爬…

二、BIO、NIO、直接内存与零拷贝

一、网络通信编程基础 1、Socket Socket是应用层与TCP/IP协议族通信的中间软件抽象层&#xff0c;是一组接口&#xff0c;由操作系统提供&#xff1b; Socket将复杂的TCP/IP协议处理和通信缓存管理都隐藏在接口后面&#xff0c;对用户来说就是使用简单的接口进行网络应用编程…

【python】OpenCV—Scanner

文章目录 1、需求描述2、代码实现3、涉及到的库函数cv2.arcLengthcv2.approxPolyDPskimage.filters.threshold_localimutils.grab_contours 4、完整代码5、参考 1、需求描述 输入图片 扫描得到如下的结果 用OpenCV构建文档扫描仪只需三个简单步骤: 1.边缘检测 2.使用图像中…

02线性表 - 链表

这里是只讲干货不讲废话的炽念&#xff0c;这个系列的文章是为了我自己以后复习数据结构而写&#xff0c;所以可能会用一种我自己能够听懂的方式来描述&#xff0c;不会像书本上那么枯燥和无聊&#xff0c;且全系列的代码均是可运行的代码&#xff0c;关键地方会给出注释^_^ 全…

windows edge自带的pdf分割工具(功能)

WPS分割pdf得会员&#xff0c;要充值&#xff01;网上一顿乱找&#xff0c;发现最简单&#xff0c;最好用&#xff0c;免费的还是回到Windows。 Windows上直接在edge浏览器打开PDF&#xff0c;点击 打印 按钮,页面下选择对应页数 打印机 选择 另存为PDF&#xff0c;然后保存就…

memcached 高性能内存对象缓存

memcached 高性能内存对象缓存 memcache是一款开源的高性能分布式内存对象缓存系统&#xff0c;常用于做大型动态web服务器的中间件缓存。 mamcached做web服务的中间缓存示意图 当web服务器接收到请求需要处理动态页面元素时&#xff0c;通常要去数据库调用数据&#xff0c;但…

ProtoBuf的安装(win+ubuntu+centos版本)

Win下安装ProtoBuf教程 WProtoBuf Win版本 上方链接就是ProtoBuf官方在Github上面的仓库&#xff0c;我这里下的是21.11版本&#xff0c;至于你要下哪个版本&#xff0c;可以根据自己的需要去下载。 首先点击链接&#xff0c;进入首页&#xff0c;向下滑就可以找到ProtoBuf…

加密传输及相关安全验证:

1.1. 加密&#xff1a; 1.1.1. 对称加密&#xff1a; 特点&#xff1a;加解密用一个密钥&#xff0c;加解密效率高&#xff0c;速度快&#xff0c;有密钥交互的问题问题&#xff1a;双方如何交互对称密钥的问题&#xff0c;用非对称密钥的公钥加密对称密钥的混合加密方式常用…

IP溯源工具--IPTraceabilityTool

工具地址&#xff1a;xingyunsec/IPTraceabilityTool: 蓝队值守利器-IP溯源工具 (github.com) 工具介绍&#xff1a; 在攻防演练期间&#xff0c;对于值守人员&#xff0c;某些客户要求对攻击IP都进行分析溯源&#xff0c;发现攻击IP的时候&#xff0c;需要针对攻击IP进行分析…

PHP手边酒店多商户版平台小程序系统源码

&#x1f3e8;【旅行新宠】手边酒店多商户版小程序&#xff0c;一键解锁住宿新体验&#xff01;&#x1f6cc; &#x1f308;【开篇&#xff1a;旅行新伴侣&#xff0c;尽在掌握】&#x1f308; 还在为旅行中的住宿选择而纠结吗&#xff1f;是时候告别繁琐的搜索和比价过程&a…

js继承之构造函数继承

最近在看js红宝书&#xff0c;学到了继承这一章节&#xff0c;看到了下图这段代码根据自己理解不明白为什么两次实例的colors值不一样 又是自己画图又是查找资料看别人如何理解的&#xff0c;今天才按自己的理解搞明白为啥。可能我的理解也是有偏差错误的&#xff0c;希望佬可以…

开源防病毒工具--ClamAV

产品文档&#xff1a;简介 - ClamAV 文档 开源地址&#xff1a;Cisco-Talos/clamav&#xff1a;ClamAV - 文档在这里&#xff1a;https://docs.clamav.net (github.com) 一、引言 ClamAV&#xff08;Clam AntiVirus&#xff09;是一个开源的防病毒工具&#xff0c;广泛应用…

【Node.js】会话控制

express 中操作 cookie cookie 是保存在浏览器端的一小块数据。 cookie 是按照域名划分保存的。 浏览器向服务器发送请求时&#xff0c;会自动将 当前域名下可用的 cookie 设置在请求头中&#xff0c;然后传递给服务器。 这个请求头的名字也叫 cookie &#xff0c;所以将 c…

NVidia 的 gpu 开源 Linux Kernel Module Driver 编译 安装 使用

见面礼&#xff0c;动态查看gpu使用情况&#xff0c;每隔2秒钟自动执行一次 nvidia-smi $ watch -n 2 nvidia-smi 1&#xff0c;找一台nv kmd列表中支持的 GPU 的电脑&#xff0c;安装ubuntu22.04 列表见 github of the kmd source code。 因为 cuda sdk 12.3支持最高到 ubu…

AMEYA360:思瑞浦推出汽车级理想二极管ORing控制器TPS65R01Q

聚焦高性能模拟芯片和嵌入式处理器的半导体供应商思瑞浦3PEAK(股票代码&#xff1a;688536)发布汽车级理想二极管ORing控制器TPS65R01Q。 TPS65R01Q拥有20mV正向调节功能&#xff0c;降低系统损耗。快速反向关断(Typ&#xff1a;0.39μs)&#xff0c;在电池反向和各种汽车电气瞬…

OCR拍照识别采购单视频介绍

千呼新零售2.0系统是零售行业连锁店一体化收银系统&#xff0c;包括线下收银线上商城连锁店管理ERP管理商品管理供应商管理会员营销等功能为一体&#xff0c;线上线下数据全部打通。 适用于商超、便利店、水果、生鲜、母婴、服装、零食、百货、宠物等连锁店使用。 详细介绍请…