Android实现网络多线程断点续传下载

Android实现网络多线程断点续传下载

本文续接我上一篇文章《Android实战:简易断点续传下载器实现》
链接地址:http://www.jianshu.com/p/5b2e22c42467

本项目Github地址:
https://github.com/liaozhoubei/MultiDownload

说到多线程下载,也许大家会觉得很迷惑,但多线程的原理实际上与单线程下载的原理并无区别。

多线程下载只需要确定好下载一个文件需要多少个线程,一般来说最好为3条线程,因为线程过多会占用系统资源,而且线程间的相互竞争也会导致下载变慢。

其次下载的时候将文件分割为三份(假设用3条线程下载)下载,在java中就要用到上次提到的RandomAccessFile这个API,它的开始结束为止用以下代码确定:

conn.setRequestProperty("Range", "bytes=" + start + "-" + end)

最后就是断点续传了,只需要才程序停止下载的时候记录下最后的下载位置就好了,当下次下载的时候从当前停止的位置开始下载。

OK,那么现在就开始我们的多线程下载+通知栏控制的实战之旅吧!

多线程断点续传下载

我们这次要做的并非简单的多线程下载,而是要做到多文件多线程的同时下载

重写布局

这次下载需要展示多个下载的文件,所以使用ListView控件,界面效果如下

下载界面.png

每个ListView的item都很简单,基本上只需要将上次写的下载界面搬过来就好了。
新建一个Layout,命名为item,将中的界面布局剪切过来,然后在中设置一个ListView空间。
activity_main.xml代码如下

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"    >

<ListView
    android:id="@+id/list_view"
    android:layout_width="match_parent"
    android:layout_height="match_parent" />
</RelativeLayout>

至于Item的布局,为了省功夫,就不写了,大家可以去我的Github下载名为MultiDownload的项目来参考。

建立FileAdapter类

布局写好了,但是ListView总是要有个Adapter类来绑定视图,填充布局的不是么,所以接下来就开始写FileAdapter了。
话说回来,ListView真的是个很重要的空间,不熟悉的小伙伴抓紧多看看怎么做吧。
创建一个继承自BaseAdapter类的FileAdapter,里面拥有以下三个成员变量:

private Context mContext = null;
private List<FileInfo> mFilelist = null;
private LayoutInflater layoutInflater;

然后重写构造函数:

    public FileAdapter(Context mContext, List<FileInfo> mFilelist) {
    this.mContext = mContext;
    this.mFilelist = mFilelist;
    layoutInflater = LayoutInflater.from(mContext);
}

再将继承的getCount/getItem/getItemId三个方法的返回值写好,用于ListView找到各自的Item。

接下来就是重头戏,重写getView方法了!
我们先定义一个静态的ViewHolder内部类,这样在ListView属性的时候才不会重复创建对象,减轻内存压力,这个谷歌官方推荐的哦!

static class ViewHolder {
    TextView textview;
    Button startButton;
    Button stopButton;
    ProgressBar progressBar;
}

然后在getView中绑定布局item中的各个控件,并且设置按钮的点击事件,getView代码如下:

@Override
public View getView(int position, View convertView, ViewGroup parent) {
    ViewHolder viewHolder = null;
    final FileInfo mFileInfo = mFilelist.get(position);
    if (convertView == null) {
        convertView = layoutInflater.inflate(R.layout.item, null);
        viewHolder = new ViewHolder();
        viewHolder.textview = (TextView) convertView.findViewById(R.id.file_textview);
        viewHolder.startButton = (Button) convertView.findViewById(R.id.start_button);
        viewHolder.stopButton = (Button) convertView.findViewById(R.id.stop_button);
        viewHolder.progressBar = (ProgressBar) convertView.findViewById(R.id.progressBar2);
        viewHolder.textview.setText(mFileInfo.getFileName());
        viewHolder.progressBar.setMax(100);
        viewHolder.startButton.setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View v) {
                Intent intent = new Intent(mContext, DownloadService.class);                    intent.setAction(DownloadService.ACTION_START);
                intent.putExtra("fileInfo", mFileInfo);
                mContext.startService(intent);
            }
        });
        viewHolder.stopButton.setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View v) {
                Intent intent = new Intent(mContext, DownloadService.class);
                intent.setAction(DownloadService.ACTION_STOP);
                intent.putExtra("fileInfo", mFileInfo);
                mContext.startService(intent);
            }
        });
        convertView.setTag(viewHolder);
    } else {
        viewHolder = (ViewHolder) convertView.getTag();
    }

    viewHolder.progressBar.setProgress(mFileInfo.getFinished());

    return convertView;
}

最后再新建一个更新进度条的方法,在获得文件ID,和当前进度之后,直接更新进度条,代码如下:

public void updataProgress(int id, int progress) {
    FileInfo info = mFilelist.get(id);
    info.setFinished(progress);
    notifyDataSetChanged();
}

好了,整个FileAdapter类就这样写完了!下面我们来修改一下MainActivity中的代码吧。

修改MainActivity代码

由于我们在FileAdapter中已经将布局写好了,而且点击事件和更新进度也是在FileAdapter中进行的,因此不需要在MainActivity中绑定按键了,现在可以将有关Button和ProgressBar的代码都删掉。然后在配置好ListView控件就可以了,代码如下:

private ListView listView;
private List<FileInfo> mFileList;
private FileAdapter mAdapter;
private String urlone = "http://www.imooc.com/mobile/imooc.apk";
private String urltwo = "http://www.imooc.com/download/Activator.exe";
private String urlthree = "http://s1.music.126.net/download/android/CloudMusic_3.4.1.133604_official.apk";
private String urlfour = "http://study.163.com/pub/study-android-official.apk";
private UIRecive mRecive;

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);     
    // 初始化控件
    listView = (ListView) findViewById(R.id.list_view);
    mFileList = new ArrayList<FileInfo>();
    // 初始化文件对象
    FileInfo fileInfo1 = new FileInfo(0, urlone, getfileName(urlone), 0, 0);
    FileInfo fileInfo2 = new FileInfo(1, urltwo, getfileName(urltwo), 0, 0);
    FileInfo fileInfo3 = new FileInfo(2, urlthree, getfileName(urlthree), 0, 0);
    FileInfo fileInfo4 = new FileInfo(3, urlfour, getfileName(urlfour), 0, 0);
    mFileList.add(fileInfo1);
    mFileList.add(fileInfo2);
    mFileList.add(fileInfo3);
    mFileList.add(fileInfo4);
    mAdapter = new FileAdapter(this, mFileList);
    listView.setAdapter(mAdapter);      
    mRecive = new UIRecive();
    // 注册广播接收器
    IntentFilter intentFilter = new IntentFilter();
    intentFilter.addAction(DownloadService.ACTION_UPDATE);
    intentFilter.addAction(DownloadService.ACTION_FINISHED);
    intentFilter.addAction(DownloadService.ACTION_START);
    registerReceiver(mRecive, intentFilter);
}

现在整个视图终于搞好了,可以启动应用,看看视图是否显示正常了。当然啦,下载还没吧下载的代码改好,现在我们就来修改下载代码吧。

修改DownloadTask代码

既然是多线程下载,那么我们便要在下载的时候设置好线程数,首先添加一个int类型的threadCount的参数代码代表线程数,初始值为1。然后在DownloadTask的构造函数中添加threadCount变量,这样在开始下载的时候就能够确定需要多少个线程下载,代码如下:

public DownloadTask(Context comtext, FileInfo fileInfo, int threadCount) {
    super();
    this.mThreadCount = threadCount;
    this.mComtext = comtext;
    this.mFileInfo = fileInfo;
    this.mDao = new ThreadDAOImple(mComtext);
}

然后我们要确认每个线程需要从文件的哪里开始下载。假设文件长度为10,分为3条线程下载,那么0-2是一份,3-5是一份,6-8是一份(java中从0开始),那么多出的一份怎么办?当然是在计算时,如果最后多出来,归最后的拿份,也就是6-9了。
我们将每个线程需要下载多少长度的文件计算好,就可以让每个文件开始自己的下载任务了,代码如下:

public void download() {
    // 从数据库中获取下载的信息
    List<ThreadInfo> list = mDao.queryThreads(mFileInfo.getUrl());
    if (list.size() == 0) {
        int length = mFileInfo.getLength();
        int block = length / mThreadCount;
        for (int i = 0; i < mThreadCount; i++) {
            // 划分每个线程开始下载和结束下载的位置
            int start = i * block;
            int end = (i + 1) * block - 1;
            if (i == mThreadCount - 1) {
                end = length - 1;
            }
            ThreadInfo threadInfo = new ThreadInfo(i, mFileInfo.getUrl(), start, end, 0);
            list.add(threadInfo);
        }
    }
    mThreadlist = new ArrayList<DownloadThread>();
    for (ThreadInfo info : list) {
        DownloadThread thread = new DownloadThread(info);
        // 使用线程池执行下载任务
        DownloadTask.sExecutorService.execute(thread);
        mThreadlist.add(thread);
        // 如果数据库不存在下载信息,添加下载信息
        mDao.insertThread(info);
    }
}

需要注意的是启动下载线程的时候在这里没有直接使用Thread.start()来启动,而是使用了线程池,因为线程过多,使用线程池便于管理。使用线程池非常简单,只需要在开始的时候定义一个线程池的成员变量:

public static ExecutorService sExecutorService = Executors.newCachedThreadPool();

然后使用

  sExecutorService.execute(需要启动的线程);

这样就能够启动线程了,是一种很简单的用法。
然后我们还要定义一个同步方法,判断一个文件的全部线程是否都下载完成,如果下载完成就弹出Toast

public synchronized void checkAllFinished() {
    boolean allFinished = true;
    for (DownloadThread thread : mThreadlist) {
        if (!thread.isFinished) {
            allFinished = false;
            break;
        }
    }
    if (allFinished == true) {
        // 下載完成后,刪除數據庫信息
        mDao.deleteThread(mFileInfo.getUrl());
        // 通知UI哪个线程完成下载
        Intent intent = new Intent(DownloadService.ACTION_FINISHED);
        intent.putExtra("fileInfo", mFileInfo);
        mComtext.sendBroadcast(intent);
    }
}

最后修改一下run方法中的代码,前面我们保存断点下载是整个文件的进度,现在保存下载是单个线程的进度,同时我们还要判断是否整个文件的所有线程是否完成的checkAllFinished方法添加进去,所以将部分代码修改为:

                // 定义UI刷新时间
                long time = System.currentTimeMillis();
                while ((len = is.read(bt)) != -1) {
                    raf.write(bt, 0, len);
                    // 累计整个文件完成进度
                    mFinished += len;
                    // 累加每个线程完成的进度
                    threadInfo.setFinished(threadInfo.getFinished() + len);
                    // 設置爲500毫米更新一次
                    if (System.currentTimeMillis() - time > 1000) {
                        time = System.currentTimeMillis();
                            // 发送已完成多少
                            intent.putExtra("finished", mFinished * 100 / mFileInfo.getLength());
                            // 表示正在下载文件的id
                            intent.putExtra("id", mFileInfo.getId());
                            Log.i("test", mFinished * 100 / mFileInfo.getLength() + "");
                            // 發送廣播給Activity
                            mComtext.sendBroadcast(intent);
                    }
                    if (mIsPause) {
                        mDao.updateThread(threadInfo.getUrl(), threadInfo.getId(), threadInfo.getFinished());
                        return;
                    }
                }
            }
            // 标识线程是否执行完毕
            isFinished = true;
            // 判断是否所有线程都执行完毕
            checkAllFinished();

好了,这样整个DownloadTask的代码就修改完了,接下来我们开始修改DownloadService中的代码了。

DownloadService代码修改

在之前的代码中是使用单线程下载,现成我们设置成可以定义多少条线程下载,因为在Handler中的启动下载的时候需要添加线程数。
同时我们要在DownloadService定义一个Map的集合,用于管理下载线程,代码如下:

private Map<Integer, DownloadTask> mTasks = new LinkedHashMap<Integer, DownloadTask>();

修改Handler代码,在启动下载线程时,添加进下载集合中,代码如下:

        switch (msg.what) {
        case MSG_INIT:
            FileInfo fileInfo = (FileInfo) msg.obj;
            Log.i("test", "INIT:" + fileInfo.toString());
            // 獲取FileInfo對象,開始下載任務
            DownloadTask task = new DownloadTask(DownloadService.this, fileInfo, 3);
            task.download();
            // 把下载任务添加到集合中
            mTasks.put(fileInfo.getId(), task);         
            break;
        }

最后要修改onStartCommand代码,当我们点击停止的时候,要停止一个文件中每一个正在运行中的线程,在点击开始的时候要用线程池启动下载,代码如下:

    if (ACTION_START.equals(intent.getAction())) {
        FileInfo fileInfo = (FileInfo) intent.getSerializableExtra("fileInfo");
        Log.i("test", "START" + fileInfo.toString());
        InitThread initThread = new InitThread(fileInfo);
        DownloadTask.sExecutorService.execute(initThread);          
    } else if (ACTION_STOP.equals(intent.getAction())) {
        FileInfo fileInfo = (FileInfo) intent.getSerializableExtra("fileInfo");
        DownloadTask task = mTasks.get(fileInfo.getId());
        if (task != null) {
            // 停止下载任务
            task.mIsPause = true;
        }
    }

这样DownloadService中的代码也修改完了,只剩下最后修改MainActivity中的代码了

修改MainActivity代码

这次我们只需修改广播接收者的代码就可以了,但我们更新进度的时候不能按照单一文件的时候更新了,我们必须按照文件的id来更新进度,这时我们可以调用FileAdapter中的updataProgress方法(前面自己定义的)便可以更新。同时我们还要在文件完成时弹出文件已完成的Toast,因此要给广播增加Action。
在onCreate中修改注册广播的代码:

    IntentFilter intentFilter = new IntentFilter();
    intentFilter.addAction(DownloadService.ACTION_UPDATE);
    intentFilter.addAction(DownloadService.ACTION_FINISHED);
    registerReceiver(mRecive, intentFilter);

修改广播接收者的代码:

    public void onReceive(Context context, Intent intent) {
        if (DownloadService.ACTION_UPDATE.equals(intent.getAction())) {
            // 更新进度条的时候
            int finished = intent.getIntExtra("finished", 0);
            int id = intent.getIntExtra("id", 0);
            mAdapter.updataProgress(id, finished);              
        } else if (DownloadService.ACTION_FINISHED.equals(intent.getAction())){
            // 下载结束的时候
            FileInfo fileInfo = (FileInfo) intent.getSerializableExtra("fileInfo");
            mAdapter.updataProgress(fileInfo.getId(), 0);
            Toast.makeText(MainActivity.this, mFileList.get(fileInfo.getId()).getFileName() + "下载完毕", Toast.LENGTH_SHORT).show();               
        }
}

大功告成!一个多线程多文件下载的项目就这样解决了,满满的成就感对不_。
但是且慢,我们还有一个通知栏没解决,等我们把通知栏做好再高兴也不迟

Notification通知栏的使用

在低版本中,Android使用通知栏是Notification这个API,但是在高版本中使用的是Notification.Builder这个API,两种区别不大,在这里使用的是低版本的Notification。

Notificaiton布局

使用通知栏必须要有个布局,但我们下拉通知栏的时候,如播放音乐,我们可以看到有上一首、下一首等按键。所以就像使用ListView一样,我们首先要定义自己的通知栏布局,布局效果如下

Notification.png

 

这是一个很简单的布局,一个TextView,一个ProgressBar,两个Button就解决了。需要说明的是,写这个布局与普通布局并无不同,布局代码如下:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical" >
<TextView
    android:id="@+id/file_textview"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content" />
<LinearLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="horizontal" >
    <ProgressBar
        android:id="@+id/progressBar2"
        style="?android:attr/progressBarStyleHorizontal"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="2" />
    <Button
        android:id="@+id/start_button"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:text="start" />
    <Button
        android:id="@+id/stop_button"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:text="stop" />
</LinearLayout>
</LinearLayout>

布局写好了,我们来定义如何操作这个视图吧!

NotificationUtil工具类

在通知栏中我们要向Activity一样找到这个布局,然后操纵它。但是不同的是通知栏使用的是RemoteViews远程视图这个API来控制视图的。

另外在新建Notification对象的时候,要设置好几个参数,这些参数是显示在状态栏中的。当QQ或者其他来通知的时候,许多时候并不是直接弹出对话框的,而是在状态栏中弹出一个提示,这就是Notification设置的参数。

好了Notification介绍到这里,下面就是NotificationUtil的完整代码:

public class NotificationUtil {
private Context mContext;
private NotificationManager mNotificationManager = null;
private Map<Integer, Notification> mNotifications = null;   
public NotificationUtil(Context context) {
    this.mContext = context;
    // 获得系统通知管理者
    mNotificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
    // 创建通知的集合
    mNotifications = new HashMap<Integer, Notification>();
}
/**
 * 显示通知栏
 * @param fileInfo
 */
public void showNotification(FileInfo fileInfo) {
    // 判断通知是否已经显示
    if(!mNotifications.containsKey(fileInfo.getId())){
        Notification notification = new Notification();
        notification.tickerText = fileInfo.getFileName() + "开始下载";
        notification.when = System.currentTimeMillis();
        notification.icon = R.drawable.ic_launcher;
        notification.flags = Notification.FLAG_AUTO_CANCEL;
        
        // 点击通知之后的意图
        Intent intent = new Intent(mContext, MainActivity.class);
        PendingIntent pd = PendingIntent.getActivity(mContext, 0, intent, 0);
        notification.contentIntent = pd;
        
        // 设置远程试图RemoteViews对象
        RemoteViews remoteViews = new RemoteViews(mContext.getPackageName(), R.layout.notification);
        // 控制远程试图,设置开始点击事件
        Intent intentStart = new Intent(mContext, DownloadService.class);
        intentStart.setAction(DownloadService.ACTION_START);
        intentStart.putExtra("fileInfo", fileInfo);
        PendingIntent piStart = PendingIntent.getService(mContext, 0, intentStart, 0);
        remoteViews.setOnClickPendingIntent(R.id.start_button, piStart);
        
        // 控制远程试图,设置结束点击事件
        Intent intentStop = new Intent(mContext, DownloadService.class);
        intentStop.setAction(DownloadService.ACTION_STOP);
        intentStop.putExtra("fileInfo", fileInfo);
        PendingIntent piStop = PendingIntent.getService(mContext, 0, intentStop, 0);
        remoteViews.setOnClickPendingIntent(R.id.stop_button, piStop);
        // 设置TextView中文件的名字
        remoteViews.setTextViewText(R.id.file_textview, fileInfo.getFileName());
        // 设置Notification的视图
        notification.contentView = remoteViews;
        // 发出Notification通知
        mNotificationManager.notify(fileInfo.getId(), notification);
        // 把Notification添加到集合中
        mNotifications.put(fileInfo.getId(), notification);
    }
}
/**
 * 取消通知栏通知
 */
public void cancelNotification(int id) {
    mNotificationManager.cancel(id);
    mNotifications.remove(id);
}
/**
 * 更新通知栏进度条
 * @param id 获取Notification的id
 * @param progress 获取的进度
 */
public void updataNotification(int id, int progress) {
    Notification notification = mNotifications.get(id);
    if (notification != null) {
        // 修改进度条进度
        notification.contentView.setProgressBar(R.id.progressBar2, 100, progress, false);
        mNotificationManager.notify(id, notification);
    }
}
}

通知栏的工具类已经写好了,现在就是使用它的时候了。我们要在Activity中点击下载的时候就弹出通知栏,下面我们就来修改DownloadService和MainActivity中的代码来启动通知栏吧。

修改DownloadService

要在点击开始下载,启动下载任务的时候弹出通知栏,我们所要知道的是如何收到开始的信号。

1、当点击开始下载的按键时,在FileAdapter的startButton传出一个ACTION_START的信号,并启动服务。

2、然后在DownloadService中的onStartCommand方法中接到信号,然后启动InitThread初始化线程。

3、在InitThread启动之后会获得FileInfo的实例,里面包含所要下载的文件的长度,然后InitThread通过Message将FileInfo实例传递给Handler。

4、在Hanlder中开启DownloadTask下载线程任务。

这个下载任务绕来绕去,还挺令人迷惑的,不过好在我们都知道它是走哪一条路了。所以在第4步,Handler开启下载任务的时候,我们就发出一个通知,告诉大家:下载已经开始啦!
代码如下:

Handler mHandler = new Handler() {
    public void handleMessage(android.os.Message msg) {
        switch (msg.what) {
        case MSG_INIT:
···
            // 发送启动下载的通知
            Intent intent = new Intent(ACTION_START);
            intent.putExtra("fileInfo", fileInfo);
            sendBroadcast(intent);
            break;
        }
    };
};

分析了一堆,我们终于获得了开始下载的通知了,然后就能在MainActivity中的广播接收器中接收到这条广播,然后弹出通知栏

MainActivity中开启通知栏

首先我们要接收到开启下载ACTION_START的这条广播,但是之前注册的广播接收器并没有包含这条广播,因此要添加这条代码:

        intentFilter.addAction(DownloadService.ACTION_START);

然后我们需要一个NotificationUtil成员对象,在onCreate中初始化它。
最后我们修改广播接收者内部类的代码,代码如下:

class UIRecive extends BroadcastReceiver {

    @Override
    public void onReceive(Context context, Intent intent) {
        if (DownloadService.ACTION_UPDATE.equals(intent.getAction())) {
            // 更新进度条的时候
            int finished = intent.getIntExtra("finished", 0);
            int id = intent.getIntExtra("id", 0);
            mAdapter.updataProgress(id, finished);
            mNotificationUtil.updataNotification(id, finished);
        } else if (DownloadService.ACTION_FINISHED.equals(intent.getAction())){
            // 下载结束的时候
            FileInfo fileInfo = (FileInfo) intent.getSerializableExtra("fileInfo");
            mAdapter.updataProgress(fileInfo.getId(), 0);
            Toast.makeText(MainActivity.this, mFileList.get(fileInfo.getId()).getFileName() + "下载完毕", Toast.LENGTH_SHORT).show();
            // 下载结束后取消通知
            mNotificationUtil.cancelNotification(fileInfo.getId());
        } else if (DownloadService.ACTION_START.equals(intent.getAction())){
            // 下载开始的时候启动通知栏
            mNotificationUtil.showNotification((FileInfo) intent.getSerializableExtra("fileInfo"));
            
        } 
    }

}

这是我们开始下载的时候就能弹出通知栏,来下载进行时能更新通知栏的进度,最后下载完成能够自动取消通知栏。
一个多线程多文件外加通知栏显示的下载器终于完成了,可以直接测试了。

总结

一个小小的简陋的项目终于完成了!但是对于刚入门的小伙伴们相信还是废了不少的功夫。

在这个项目中,我们运用的不再是单一的组件只是,而是将组件综合运用起来,如何在listView中操作,数据库如何增删改查,Service如何与Activity通信,Notification通知栏又是怎样显示的····

这些组件我们都刷了一遍,相信下次再次使用的时候就不会像刚开始一样无从下手了。

这个项目看上去貌似不错,但仔细思量仍是有种种的不足之处,还拥有一些BUG待解决。而且在Activity与Service之间的通信用BroadCast广播,虽然会更简单些,但对于真正的项目而已可能不是这样的。

因为广播是系统组件,这样大材小用是资源的浪费,而且效率是偏低的。在一个项目中的单线程多进程中,应该使用Handler加上Messenger进行通信的,这有待于大家学习。

好了,话就说到这里,这个项目的Github地址是:
https://github.com/liaozhoubei/MultiDownload
欢迎大家下载,如果发现有BUG,也可以通知我


 

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值