aemg动画教程(分享7日MG动画全套教程)
2022-01-12 13:36:05

1.概述

在深入了解Apng动画播放之前,我们需要对Apng的结构有所了解,具体参见Apng动画介绍,对Apng的整体结构有所了解后,下面我们来讲讲Apng动画的播放,主要包括Apng解析和Apng渲染两个过程。

2. Apng动画播放流程

Apng动画播放流程包括Apng解析和Apng渲染两个过程,Apng解析主要有两种方法,下面我们将会介绍,而Apng渲染主要包括三个步骤:消除(dispose)、合成(blend)、绘制(draw),由此得到Apng动画播放流程图如下:

Android-Apng动画的播放,大牛手把手动态教学,你都不愿意看吗?

Apng动画播放流程

3. Apng的解析

Apng的解析主要是将Apng文件转化成Apng序列帧Frame-n,从上面的流程图可知,Apng文件的解析列出了两种方案,下面来分别说说:

1)Apng文件首先经过一个解压(ApngExact)的过程,生成png序列帧保存在本地,然后经过加载(LoadPng)处理生成序列帧Frame-n。假设Apng动画文件总共有90帧,那么经过ApngExact处理后,会生成90张png序列帧保存在本地,每帧通过LoadPng处理生成Bitmap并供后面的Apng渲染使用。

2)Apng是一个独立的文件,我们自己编写读取Apng文件的代码类:ApngReader,当渲染第i帧时,通过ApngReader直接获取第i帧的Bitmap。

比较:

1)方案一是将Apng文件全部解压成png序列图片保存在本地,方案二是把Apng文件当做一个整体去处理,需要第几帧直接读取第几帧,并将该帧以Bitmap的形似保存到内存。

2)方案一解压得到的png图片在后面的渲染中需要转化成Bitamp,而方案二直接就获取了第几帧的Bitmap,相比于方案一,方案二减少了一个从SD卡读取png文件的操作。

4. Apng的渲染

方案一的具体实现大家可以参考github上面的一个项目apng-view,下面我们来讲讲方案二的具体实现,即ApngReader的具体实现。

1) 解析Apng的每一帧我们是将整个文件放到一个buffer里面,并且通过RandomAccessFile、MappedByteBuffer来读取Apng的每一帧,ApngReader的构造函数如下:

public ApngReader(String apngFile) throws IOException, FormatNotSupportException {
        RandomAccessFile f = new RandomAccessFile(apngFile, "r");
        mBuffer = f.getChannel().map(FileChannel.MapMode.READ_ONLY, 0, f.length());
        f.close();
        if (mBuffer.getInt() != PNG_SIG
                && mBuffer.getInt(4) != PNG_SIG_VER
                && mBuffer.getInt(8) != CODE_IHDR) {
            throw new FormatNotSupportException("Not a png/apng file");
        }
        mChunk = new ApngMmapParserChunk(mBuffer);
        reset();
    }

下面来看看读取每一帧的方法:

/**
     * get next frame control info & bitmap
     *
     * @return next frame control info, or null if no next FCTL chunk || no next IDAT/FDAT
     * @throws IOException
     */
    public ApngFrame nextFrame() throws IOException {
        // reset read pointers from previous frames lock
        mPngStream.clearDataChunks();
        mPngStream.resetPos();
        mChunk.unlockRead();

        // locate next FCTL chunk
        boolean ihdrCopied = false;
        while (mChunk.typeCode != CODE_fcTL) {
            switch (mChunk.typeCode) {
                case CODE_IEND:
                    return null;
                case CODE_IHDR:
                    mPngStream.setIHDR(mChunk.duplicateData());
                    break;
                case CODE_acTL:
                    handleACTL(mChunk);
                    ihdrCopied = true;
                    break;
                default:
                    handleOtherChunk(mChunk);
            }
            mChunk.parseNext();
        }

        // located at FCTL chunk
        ApngFrame frame = new ApngFrame();
        mChunk.assignTo(frame);

        // locate next IDAT or fdAt chunk
        mChunk.parseNext();// first move next from current FCTL
        while (mChunk.typeCode != CODE_IDAT && mChunk.typeCode != CODE_fdAT) {
            switch (mChunk.typeCode) {
                case CODE_IEND:
                    return null;
                case CODE_IHDR:
                    mPngStream.setIHDR(mChunk.duplicateData());
                    ihdrCopied = true;
                    break;
                case CODE_acTL:
                    handleACTL(mChunk);
                    break;
                default:
                    handleOtherChunk(mChunk);
            }
            mChunk.parseNext();
        }

        // located at first IDAT or fdAT chunk
        // collect all consecutive dat chunks
        boolean needUpdateIHDR = true;
        int dataOffset = mChunk.getOffset();
        while (mChunk.typeCode == CODE_fdAT || mChunk.typeCode == CODE_IDAT) {
            if (needUpdateIHDR && (!ihdrCopied || mChunk.typeCode == CODE_fdAT)) {
                mPngStream.updateIHDR(frame.getWidth(), frame.getHeight());
                needUpdateIHDR = false;
            }

            if (mChunk.typeCode == CODE_fdAT) {
                mPngStream.addDataChunk(new Fdat2IdatChunk(mChunk));
            } else {
                mPngStream.addDataChunk(new ApngMmapParserChunk(mChunk));
            }
            mChunk.parseNext();
        }

        // lock position for this frames image as OutputStream
        mChunk.lockRead(dataOffset);
        frame.imageStream = mPngStream;
        return frame;
    }

2) Apng的消除操作Apng的消除操作是在ApngFrameRender的render方法做的,方法如下:

    /**
     * 渲染当前帧画面
     *
     * @param frame apng中当前帧
     * @return 渲染合成后的当前帧图像
     */
    public Bitmap render(ApngFrame frame, Bitmap frameBmp) {
        // 执行消除操作
        dispose(frame);
        // 合成当前帧
        blend(frame, frameBmp);
        return mRenderFrame;
    }

dispose(ApngFrame frame)方法如下:

/**
     * 帧图像析构消除 - 提交结果
     */
    private void dispose(ApngFrame frame) {
        // last frame dispose op
        switch (mLastDisposeOp) {
            case APNG_DISPOSE_OP_NONE:
                // no op
                break;

            case APNG_DISPOSE_OP_BACKGROUND:
                // clear rect
                mRenderCanvas.clipRect(mDisposeRect);
                mRenderCanvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);
                mRenderCanvas.clipRect(mFullRect, Region.Op.REPLACE);
                break;

            case APNG_DISPOSE_OP_PREVIOUS:
                // swap work and cache bitmap
                Bitmap bmp = mRenderFrame;
                mRenderFrame = mDisposedFrame;
                mDisposedFrame = bmp;
                mRenderCanvas.setBitmap(mRenderFrame);
                mDisposeCanvas.setBitmap(mDisposedFrame);
                break;
        }

        // current frame dispose op
        mLastDisposeOp = frame.getDisposeOp();
        switch (mLastDisposeOp) {
            case APNG_DISPOSE_OP_NONE:
                // no op
                break;

            case APNG_DISPOSE_OP_BACKGROUND:
                // cache rect for next clear dispose
                int x = frame.getxOff();
                int y = frame.getyOff();
                mDisposeRect.set(x, y, x + frame.getWidth(), y + frame.getHeight());
                break;

            case APNG_DISPOSE_OP_PREVIOUS:
                // cache bmp for next restore dispose
                mDisposeCanvas.clipRect(mFullRect, Region.Op.REPLACE);
                mDisposeCanvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);
                mDisposeCanvas.drawBitmap(mRenderFrame, 0, 0, null);
                break;
        }
    }

3) Apng的合成操作Apng的合成操作是在每一帧经过dispose之后做的,具体方法是blend(ApngFrame frame, Bitmap frameBmp),代码如下:

 /**
     * 帧图像合成
     */
    private void blend(ApngFrame frame, Bitmap frameBmp) {
        int xOff = frame.getxOff();
        int yOff = frame.getyOff();

        mRenderCanvas.clipRect(xOff, yOff, xOff + frame.getWidth(), yOff + frame.getHeight());
        if (frame.getBlendOp() == APNG_BLEND_OP_SOURCE) {
            mRenderCanvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);
        }
        mRenderCanvas.drawBitmap(frameBmp, xOff, yOff, null);
        mRenderCanvas.clipRect(mFullRect, Region.Op.REPLACE);
    }

4) Apng的绘制Apng的每一帧经过消除、合成操作之后,就可以在View上面draw,具体代码如下:

 /**
         * draw the appointed frame
         */
        private void drawFrame(AnimParams animItem, ApngFrame frame, Bitmap frameBmp) {
            if (surfaceEnabled && !isInterrupted()) {
                //start to draw the frame
                try {
                    Matrix matrix = new Matrix();
                    matrix.setScale(mScale, mScale);
                    Bitmap bmp = mFrameRender.render(frame, frameBmp);

                    //saveBitmap(bmp, index);
                    index ++;

                    Canvas canvas = getHolder().lockCanvas();
                    //anti-aliasing
                    canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);
                    float[] tranLeftAndTop = ApngUtils.getTranLeftAndTop(canvas, bmp, animItem.align, mScale, animItem.percent);
                    canvas.setDrawFilter(new PaintFlagsDrawFilter(0, Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG));
                    matrix.postTranslate(tranLeftAndTop[0], tranLeftAndTop[1]);
                    canvas.drawBitmap(bmp, matrix, null);
                    getHolder().unlockCanvasAndPost(canvas); //  unlock the canvas
                } catch (Exception e) {
                    Log.e(TAG, "draw error msg:" + Log.getStackTraceString(e));
                }
            }
        }
  1. 实例我们是在SurfaceView上面来绘制Apng的每一帧,例子如下:

Activity代码:

public class MainActivity extends Activity{
    private ApngSurfaceView mApngSurfaceView;
    private static final  String COLOR_BALL_IMAGE_PATH = "assets://color_ball.png";
    //private static final  String CAR_IMAGE_PATH = "assets://car.png";


    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        mApngSurfaceView = (ApngSurfaceView)findViewById(R.id.apng_surface_view);
        Button startPlay = (Button) findViewById(R.id.start_play);
        startPlay.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                playAnim();
            }
        });
    }


    private void playAnim(){
        File file = FileUtils.processApngFile(COLOR_BALL_IMAGE_PATH, this);
        if(file == null) return;
        AnimParams animItem = new AnimParams();
        animItem.align = 2;
        animItem.imagePath = file.getAbsolutePath();
        animItem.isHasBackground = true;
        animItem.percent = 0.5f;
        mApngSurfaceView.addApngForPlay(animItem);
    }
}

Layout代码:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@android:color/white"
    tools:context=".MainActivity">

    <com.apng.ApngSurfaceView
        android:id="@+id/apng_surface_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_gravity="center" />
    <Button
        android:id="@+id/start_play"
        android:text="@string/play"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />

</RelativeLayout>

我们把一张Apng图片放到Asset目录下,通过ApngSurfaceView来播放。

5. 说明

例子演示:

Android-Apng动画的播放,大牛手把手动态教学,你都不愿意看吗?

1.概述

在深入了解Apng动画播放之前,我们需要对Apng的结构有所了解,具体参见Apng动画介绍,对Apng的整体结构有所了解后,下面我们来讲讲Apng动画的播放,主要包括Apng解析和Apng渲染两个过程。

2. Apng动画播放流程

Apng动画播放流程包括Apng解析和Apng渲染两个过程,Apng解析主要有两种方法,下面我们将会介绍,而Apng渲染主要包括三个步骤:消除(dispose)、合成(blend)、绘制(draw),由此得到Apng动画播放流程图如下:

Android-Apng动画的播放,大牛手把手动态教学,你都不愿意看吗?

Apng动画播放流程

3. Apng的解析

Apng的解析主要是将Apng文件转化成Apng序列帧Frame-n,从上面的流程图可知,Apng文件的解析列出了两种方案,下面来分别说说:

1)Apng文件首先经过一个解压(ApngExact)的过程,生成png序列帧保存在本地,然后经过加载(LoadPng)处理生成序列帧Frame-n。假设Apng动画文件总共有90帧,那么经过ApngExact处理后,会生成90张png序列帧保存在本地,每帧通过LoadPng处理生成Bitmap并供后面的Apng渲染使用。

2)Apng是一个独立的文件,我们自己编写读取Apng文件的代码类:ApngReader,当渲染第i帧时,通过ApngReader直接获取第i帧的Bitmap。

比较:

1)方案一是将Apng文件全部解压成png序列图片保存在本地,方案二是把Apng文件当做一个整体去处理,需要第几帧直接读取第几帧,并将该帧以Bitmap的形似保存到内存。

2)方案一解压得到的png图片在后面的渲染中需要转化成Bitamp,而方案二直接就获取了第几帧的Bitmap,相比于方案一,方案二减少了一个从SD卡读取png文件的操作。

4. Apng的渲染

方案一的具体实现大家可以参考github上面的一个项目apng-view,下面我们来讲讲方案二的具体实现,即ApngReader的具体实现。

1) 解析Apng的每一帧我们是将整个文件放到一个buffer里面,并且通过RandomAccessFile、MappedByteBuffer来读取Apng的每一帧,ApngReader的构造函数如下:

public ApngReader(String apngFile) throws IOException, FormatNotSupportException {
        RandomAccessFile f = new RandomAccessFile(apngFile, "r");
        mBuffer = f.getChannel().map(FileChannel.MapMode.READ_ONLY, 0, f.length());
        f.close();
        if (mBuffer.getInt() != PNG_SIG
                && mBuffer.getInt(4) != PNG_SIG_VER
                && mBuffer.getInt(8) != CODE_IHDR) {
            throw new FormatNotSupportException("Not a png/apng file");
        }
        mChunk = new ApngMmapParserChunk(mBuffer);
        reset();
    }

下面来看看读取每一帧的方法:

/**
     * get next frame control info & bitmap
     *
     * @return next frame control info, or null if no next FCTL chunk || no next IDAT/FDAT
     * @throws IOException
     */
    public ApngFrame nextFrame() throws IOException {
        // reset read pointers from previous frames lock
        mPngStream.clearDataChunks();
        mPngStream.resetPos();
        mChunk.unlockRead();

        // locate next FCTL chunk
        boolean ihdrCopied = false;
        while (mChunk.typeCode != CODE_fcTL) {
            switch (mChunk.typeCode) {
                case CODE_IEND:
                    return null;
                case CODE_IHDR:
                    mPngStream.setIHDR(mChunk.duplicateData());
                    break;
                case CODE_acTL:
                    handleACTL(mChunk);
                    ihdrCopied = true;
                    break;
                default:
                    handleOtherChunk(mChunk);
            }
            mChunk.parseNext();
        }

        // located at FCTL chunk
        ApngFrame frame = new ApngFrame();
        mChunk.assignTo(frame);

        // locate next IDAT or fdAt chunk
        mChunk.parseNext();// first move next from current FCTL
        while (mChunk.typeCode != CODE_IDAT && mChunk.typeCode != CODE_fdAT) {
            switch (mChunk.typeCode) {
                case CODE_IEND:
                    return null;
                case CODE_IHDR:
                    mPngStream.setIHDR(mChunk.duplicateData());
                    ihdrCopied = true;
                    break;
                case CODE_acTL:
                    handleACTL(mChunk);
                    break;
                default:
                    handleOtherChunk(mChunk);
            }
            mChunk.parseNext();
        }

        // located at first IDAT or fdAT chunk
        // collect all consecutive dat chunks
        boolean needUpdateIHDR = true;
        int dataOffset = mChunk.getOffset();
        while (mChunk.typeCode == CODE_fdAT || mChunk.typeCode == CODE_IDAT) {
            if (needUpdateIHDR && (!ihdrCopied || mChunk.typeCode == CODE_fdAT)) {
                mPngStream.updateIHDR(frame.getWidth(), frame.getHeight());
                needUpdateIHDR = false;
            }

            if (mChunk.typeCode == CODE_fdAT) {
                mPngStream.addDataChunk(new Fdat2IdatChunk(mChunk));
            } else {
                mPngStream.addDataChunk(new ApngMmapParserChunk(mChunk));
            }
            mChunk.parseNext();
        }

        // lock position for this frames image as OutputStream
        mChunk.lockRead(dataOffset);
        frame.imageStream = mPngStream;
        return frame;
    }

2) Apng的消除操作Apng的消除操作是在ApngFrameRender的render方法做的,方法如下:

    /**
     * 渲染当前帧画面
     *
     * @param frame apng中当前帧
     * @return 渲染合成后的当前帧图像
     */
    public Bitmap render(ApngFrame frame, Bitmap frameBmp) {
        // 执行消除操作
        dispose(frame);
        // 合成当前帧
        blend(frame, frameBmp);
        return mRenderFrame;
    }

dispose(ApngFrame frame)方法如下:

/**
     * 帧图像析构消除 - 提交结果
     */
    private void dispose(ApngFrame frame) {
        // last frame dispose op
        switch (mLastDisposeOp) {
            case APNG_DISPOSE_OP_NONE:
                // no op
                break;

            case APNG_DISPOSE_OP_BACKGROUND:
                // clear rect
                mRenderCanvas.clipRect(mDisposeRect);
                mRenderCanvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);
                mRenderCanvas.clipRect(mFullRect, Region.Op.REPLACE);
                break;

            case APNG_DISPOSE_OP_PREVIOUS:
                // swap work and cache bitmap
                Bitmap bmp = mRenderFrame;
                mRenderFrame = mDisposedFrame;
                mDisposedFrame = bmp;
                mRenderCanvas.setBitmap(mRenderFrame);
                mDisposeCanvas.setBitmap(mDisposedFrame);
                break;
        }

        // current frame dispose op
        mLastDisposeOp = frame.getDisposeOp();
        switch (mLastDisposeOp) {
            case APNG_DISPOSE_OP_NONE:
                // no op
                break;

            case APNG_DISPOSE_OP_BACKGROUND:
                // cache rect for next clear dispose
                int x = frame.getxOff();
                int y = frame.getyOff();
                mDisposeRect.set(x, y, x + frame.getWidth(), y + frame.getHeight());
                break;

            case APNG_DISPOSE_OP_PREVIOUS:
                // cache bmp for next restore dispose
                mDisposeCanvas.clipRect(mFullRect, Region.Op.REPLACE);
                mDisposeCanvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);
                mDisposeCanvas.drawBitmap(mRenderFrame, 0, 0, null);
                break;
        }
    }

3) Apng的合成操作Apng的合成操作是在每一帧经过dispose之后做的,具体方法是blend(ApngFrame frame, Bitmap frameBmp),代码如下:

 /**
     * 帧图像合成
     */
    private void blend(ApngFrame frame, Bitmap frameBmp) {
        int xOff = frame.getxOff();
        int yOff = frame.getyOff();

        mRenderCanvas.clipRect(xOff, yOff, xOff + frame.getWidth(), yOff + frame.getHeight());
        if (frame.getBlendOp() == APNG_BLEND_OP_SOURCE) {
            mRenderCanvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);
        }
        mRenderCanvas.drawBitmap(frameBmp, xOff, yOff, null);
        mRenderCanvas.clipRect(mFullRect, Region.Op.REPLACE);
    }

4) Apng的绘制Apng的每一帧经过消除、合成操作之后,就可以在View上面draw,具体代码如下:

 /**
         * draw the appointed frame
         */
        private void drawFrame(AnimParams animItem, ApngFrame frame, Bitmap frameBmp) {
            if (surfaceEnabled && !isInterrupted()) {
                //start to draw the frame
                try {
                    Matrix matrix = new Matrix();
                    matrix.setScale(mScale, mScale);
                    Bitmap bmp = mFrameRender.render(frame, frameBmp);

                    //saveBitmap(bmp, index);
                    index ++;

                    Canvas canvas = getHolder().lockCanvas();
                    //anti-aliasing
                    canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);
                    float[] tranLeftAndTop = ApngUtils.getTranLeftAndTop(canvas, bmp, animItem.align, mScale, animItem.percent);
                    canvas.setDrawFilter(new PaintFlagsDrawFilter(0, Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG));
                    matrix.postTranslate(tranLeftAndTop[0], tranLeftAndTop[1]);
                    canvas.drawBitmap(bmp, matrix, null);
                    getHolder().unlockCanvasAndPost(canvas); //  unlock the canvas
                } catch (Exception e) {
                    Log.e(TAG, "draw error msg:" + Log.getStackTraceString(e));
                }
            }
        }
  1. 实例我们是在SurfaceView上面来绘制Apng的每一帧,例子如下:

Activity代码:

public class MainActivity extends Activity{
    private ApngSurfaceView mApngSurfaceView;
    private static final  String COLOR_BALL_IMAGE_PATH = "assets://color_ball.png";
    //private static final  String CAR_IMAGE_PATH = "assets://car.png";


    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        mApngSurfaceView = (ApngSurfaceView)findViewById(R.id.apng_surface_view);
        Button startPlay = (Button) findViewById(R.id.start_play);
        startPlay.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                playAnim();
            }
        });
    }


    private void playAnim(){
        File file = FileUtils.processApngFile(COLOR_BALL_IMAGE_PATH, this);
        if(file == null) return;
        AnimParams animItem = new AnimParams();
        animItem.align = 2;
        animItem.imagePath = file.getAbsolutePath();
        animItem.isHasBackground = true;
        animItem.percent = 0.5f;
        mApngSurfaceView.addApngForPlay(animItem);
    }
}

Layout代码:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@android:color/white"
    tools:context=".MainActivity">

    <com.apng.ApngSurfaceView
        android:id="@+id/apng_surface_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_gravity="center" />
    <Button
        android:id="@+id/start_play"
        android:text="@string/play"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />

</RelativeLayout>

我们把一张Apng图片放到Asset目录下,通过ApngSurfaceView来播放。

5. 说明

例子演示:

Android-Apng动画的播放,大牛手把手动态教学,你都不愿意看吗?

未解决
您可能还需要
天天领红包,单单有红包
aero特效怎么开启,win7开启aero特效的方法
ae2018中英文切换工具(ae语言设置方法)
打印代提交的审批单支持显示代提交人吗?
群收款可以向单人收款么?
3万元以内的投入,可以开办的家庭作坊式工厂,这几个项目有前景
可以查看会议室历史预约记录吗?
家校群为什么系统会自动单聊老师
主播直播卡顿
个人认证常见问题
管理专员已考过,为什么数字化管理师不能考试?
b6手环功能介绍(华为b6智能手环评测)
b360主板配什么cpu最佳(b360主板支持cpu列表)
b360主板能装win7吗(b360主板装win7的方法)
b2c电子商务网站都有哪些(讲解b2b电子商务模式)
b2c电商系统有哪些(电商系统物流系统操作)
b2c电商排名,b2C电子商务企业排名
b2c电商平台制作流程(跨境电商的主要平台操作方法)
b2c平台成功的关键是什么(最重要的6个关键性因素)
b2c企业有哪些公司(精选这10大B2C电商平台)
b2b营销是什么意思(附B2B与B2C营销优缺点)
b2b网站有哪些特点,十大国内b2b网站排名
b2b推广的社交网络,b2b平台免费发布信息网
b2b推广方式有哪些,免费b2b推广技巧
b2b平台推广策略有哪些,B2B网站推广4个技巧
b2b平台免费推广ppt(全程ppt解析B2B细则)
b2b市场营销是什么(附最实用的6种B2B营销策略)
b2b医药电商平台排名,中国十大医药电商平台介绍
b2b医药电商平台排名,中国医药电商10强是哪些
b2b2c网站建设模式及案例(简述s2b2c和b2b2c的区别)
b2b2c模式有哪些平台(盘点适合b2b2c商业模式)
正在加载...