Android 多线程下载以及断点续传

news2024/10/6 12:31:16

在这里插入图片描述

多线程下载

在日常开发中,我们不可避免的会接到类似这样的需求,下载一个比较大的素材文件或者安装包文件,以此实现APP的自动更新,APP内的素材替换等。由于一般此类文件都比较大,一般会在50M以上,如果我们不对下载的进度进行记录的话,那么对于用户的流量的损耗和体验,都是比较糟糕的。所以我们自然而然的就会想到断点续传,同时为了充分压榨用户的带宽,使一些文件能够尽快的下载完成,那么我们也可以使用多线程同时下载的技术加快文件的下载速度。

举个例子,我们要从一个水缸中用抽水机通过水管抽水,由于管子的直径等等的限制,我们单条管子无法完全利用我们的抽水机的抽水动力。因此我们就将这些抽水的任务分成了多份,分摊到多个管子上,这样就可以更充分的利用我们的抽水机动力,从而提高抽水的速度。

因此,我们使用多线程下载的主要意义就是——提高下载速度。

多线程下载的原理

简单来讲,多线程下载原理其实就是讲一个文件逻辑区分了几块,每个线程分别独立地下载自己负责的区块。

所以我们可以简单地讲一个文件的大小平均分成几份即可。
在这里插入图片描述

既然要分配文件的区块,那么我们就要知道文件的总大小,文件的总大小我们可以通过网络请求进行获取,在 Response Headers 中的 Content-Length 字段。也就是该文件的大小,单位是字节。

简单抽象出来

    /**
     * 获取需要下载链接的文件长度
     * @param url 链接
     */
    @WorkerThread
    fun obtainTotalSize(url: String): Long

获取文件指定区域的内容

任务分配我们已经了解了,看起来很理想,但有一个问题,我们如何实现向服务器只请求这个文件的某一段而不是全部呢?

我们可以通过在请求头中加入 Range 字段来指定请求的范围,从而实现指定某一段的数据。如:RANGE bytes=10000-19999 就指定了 10000-19999 这段字节的数据所以我们的核心思想就是通过它拿到文件对应字节段的 InputStream,然后对它读取并写入文件。

抽象出来即:

    /**
     * 获取文件分段内的内容
     * @param url 链接
     * @param start 开始位置
     * @param end 结束位置
     * @return 输入流
     */
    @WorkerThread
    fun obtainStreamByRange(url: String, start: Long, end: Long): InputStream?

文件的指定位置的写入

获取到对应的内容,那么我们就要在文件的指定区域去写入,由于我们是多线程下载,因此文件并不是每次都是从前往后一个个字节写入的,随时可能在文件的任何一个地方写入数据。因此我们需要能够在文件的指定位置写入数据。这里我们用到了RandomAccessFile 来实现这个功能。

RandomAccessFile 是一个随机访问文件类,同时整合了 FileOutputStream 和 FileInputStream,支持从文件的任何字节处读写数据。通过它我们就可以在文件的任何字节处写入数据。

接下来简单讲讲我们这里是如何使用 RandomAccessFile 的。我们对于每个子任务来说都有一个开始和结束的位置。每个任务都可以通过 RandomAccessFile::seek 跳转到文件的对应字节位置,然后从该位置开始读取 InputStream 并写入。这样,就实现了不同线程对文件的随机写入。

断点续传

那么文件的写入搞定了,那么就剩下最后一个文件,如何实现断点续传。这里其实我们可以记录每一次写入文件的进度,当下载任务被暂停的时候,我们就将对应的任务记录下载,记录相应的url,存储文件,当前下载的进度等基本信息,当用户再次出发的时候我们就可以从这些信息恢复进度,继续下载。

简单来讲,只需要我们将对应的任务进行持久化即可。

代码实现

首先我们需要定义任务的下载的各个阶段的状态。

/**
 * Author: huangtao
 * Date: 2022/12/27
 * Desc: 下载状态枚举
 */
enum class DownloadStatus {

    /**
     * 空闲,默认状态
     */
    IDLE,

    /**
     * 完成
     */
    COMPLETED,

    /**
     * 下载中
     */
    DOWNLOADING,

    /**
     * 暂停
     */
    PAUSE,

    /**
     * 出错
     */
    ERROR
}

相应的监听回调

/**
 * Author: huangtao
 * Date: 2022/12/27
 * Desc: 下载的事件监听
 */
interface DownloadListener {

    /**
     * 开始下载
     */
    fun onStart() {}

    /**
     * 下载中
     * @param progress 进度 字节
     * @param total 总数 字节
     */
    fun onDownloading(progress: Long, total: Long) {}

    /**
     * 暂停
     */
    fun onPause() {}

    /**
     * 取消下载
     */
    fun onCancel() {}

    /**
     * 下载完成
     */
    fun onComplete() {}

    /**
     * 出错
     * @param msg 错误信息
     */
    fun onError(msg: String) {}
}

Http的抽象辅助类


/**
 * Author: huangtao
 * Date: 2022/12/27
 * Desc: 下载的网络请求接口定义
 */
interface DownloadHttpHelper {

    /**
     * 获取需要下载链接的文件长度
     * @param url 链接
     */
    @WorkerThread
    fun obtainTotalSize(url: String): Long

    /**
     * 获取文件分段内的内容
     * @param url 链接
     * @param start 开始位置
     * @param end 结束位置
     * @return 输入流
     */
    @WorkerThread
    fun obtainStreamByRange(url: String, start: Long, end: Long): InputStream?
}

持久化的辅助类

/**
 * Author: huangtao
 * Date: 2022/12/27
 * Desc: 下载的db存储接口定义
 */
interface DownloadDbHelper {

    /**
     * 删除一个任务
     * @param task 下载子任务
     */
    fun delete(task : SubDownloadTask)

    /**
     * 添加一个子任务
     * @param task 子任务
     */
    fun insert(task : SubDownloadTask)

    /**
     * 更新一个任务
     * @param task 子任务
     */
    fun update(task : SubDownloadTask)

    /**
     * 根据url查询相关任务
     * @param url 下载链接
     * @return 相关子任务 无:返回空列表
     */
    fun queryByTaskTag(url : String):List<SubDownloadTask>
}

一些通用的配置

/**
 * Author: huangtao
 * Date: 2022/12/27
 * Desc: 下载配置接口
 */
object DownloadConfig {

    const val TAG = "DownloadManager"

    /**
     * 上下文
     */
    lateinit var context: Application
        private set

    /**
     * db实现
     */
    var dbHelper: DownloadDbHelper
        private set

    /**
     * http下载实现
     */
    var httpHelper: DownloadHttpHelper
        private set

    /**
     * 线程数
     */
    var threadNum: Int
        private set

    /**
     * 线程池
     */
    var mExecutorService: Executor
        private set


    init {
        threadNum = 4
        dbHelper = DownloadDbImpl()
        httpHelper = DownloadHttpImpl()
        mExecutorService = Dispatchers.IO.asExecutor()
    }

    /**
     * 必须要设置
     * 设置上下文
     */
    fun setContext(app: Application): DownloadConfig {
        context = app
        return this
    }

    /**
     * 设置自定义的DownloadDbHelper
     * 默认使用sqlite
     */
    fun setDbHelper(impl: DownloadDbHelper): DownloadConfig {
        dbHelper = impl
        return this
    }

    /**
     * 设置自定义的DownloadHttpHelper
     * 默认使用HttpURLConnection
     */
    fun setHttpHelper(impl: DownloadHttpHelper): DownloadConfig {
        httpHelper = impl
        return this
    }

    /**
     * 设置线程数
     * 默认 4
     */
    fun setThreadNum(num: Int): DownloadConfig {
        threadNum = num
        return this
    }

    /**
     * 设置线程池
     * 默认采用 协程IO线程池
     */
    fun setExecutor(executor: Executor): DownloadConfig {
        mExecutorService = executor
        return this
    }
}

子任务的实现

/**
 * Author: huangtao
 * Date: 2022/12/27
 * Desc: 子任务下载类
 */
class SubDownloadTask(
    //下载路径
    val url: String,
    //子任务的大小
    val taskSize: Long,
    //开始位置
    val startPos: Long,
    //结束位置
    val endPos: Long,
    //当前位置
    var currentPos: Long = startPos,
    //保存的文件
    val saveFile: File,
    //回调监听
    var listener: DownloadListener? = null
) : Runnable {

    companion object {
        const val BUFFER_SIZE: Long = 1024 * 1024
    }

    /**
     * 当前任务的状态
     */
    @Volatile
    var status: DownloadStatus = DownloadStatus.IDLE

    /**
     * 已经下载的大小
     */
    @Volatile
    var completeSize = 0L

    /**
     * 暂停任务
     */
    fun pause() {
        status = DownloadStatus.PAUSE
    }

    override fun run() {
        try {
            status = DownloadStatus.DOWNLOADING
            listener?.onStart()
            val input = DownloadConfig.httpHelper.obtainStreamByRange(url, currentPos, endPos)
                ?: throw java.lang.NullPointerException("obtainStreamByRange InputStream is null")
            writeFile(input)
        } catch (e: Exception) {
            Log.e(DownloadConfig.TAG, e.message ?: "", e)
            status = DownloadStatus.ERROR
            listener?.onError(e.message ?: "")
        }
    }

    @Throws(IOException::class)
    private fun writeFile(input: InputStream) {
        Log.i(
            DownloadConfig.TAG,
            "${DownloadConfig.TAG}{${hashCode()}},写入开始 线程名:${Thread.currentThread().name} " +
                    "文件路径:${saveFile.absolutePath}"
        )
        val file = RandomAccessFile(saveFile, "rwd")
        val bufferSize = BUFFER_SIZE.coerceAtMost(taskSize).toInt()
        val buffer = ByteArray(bufferSize)
        file.seek(currentPos)
        while (true) {
            if (status != DownloadStatus.DOWNLOADING) {
                break
            }
            val offset = input.read(buffer, 0, bufferSize)
            if (offset == -1) {
                break
            }
            file.write(buffer, 0, offset)
            currentPos += offset
            completeSize += offset
            listener?.onDownloading(offset.toLong(), taskSize)
        }
        //更新状态
        if (status == DownloadStatus.DOWNLOADING) {
            status = DownloadStatus.COMPLETED
        }
        DownloadConfig.dbHelper.update(this)
        //处理回调
        if (status == DownloadStatus.COMPLETED) {
            listener?.onComplete()
        } else if (status == DownloadStatus.PAUSE) {
            listener?.onPause()
        }
        //关闭资源
        file.close()
        input.close()

        Log.i(
            DownloadConfig.TAG, "${DownloadConfig.TAG}{${hashCode()}},\n 写入状态:${status.name} " +
                    "总大小=${taskSize} 开始位置${startPos} " +
                    "结束位置${endPos} 完成大小${completeSize}"
        )
    }
}

总任务的实现

/**
 * Author: huangtao
 * Date: 2022/12/27
 * Desc:
 */
class DownloadTask(
    //链接
    val url: String,
    //保存路径
    private val savePath: String,
    //回调监听
    val listener: DownloadListener
) : DownloadListener {

    /**
     * 完成大小
     */
    @Volatile
    var completeSize: Long = 0
        private set

    /**
     * 文件总大小
     */
    private var totalSize: Long = 0

    /**
     * 状态
     */
    var status: DownloadStatus = DownloadStatus.IDLE
        private set

    /**
     * 线程数
     */
    private val threadNum = DownloadConfig.threadNum

    /**
     * 子任务列表
     */
    private val subTasks = mutableListOf<SubDownloadTask>()

    /**
     * 线程池
     */
    private val mExecutorService: Executor by lazy {
        DownloadConfig.mExecutorService
    }

    /**
     * 开始下载
     * 如果是暂停的则从上次的位置继续下载
     */
    fun download() {
        mExecutorService.execute {
            if (status == DownloadStatus.DOWNLOADING) {
                return@execute
            }
            status = DownloadStatus.DOWNLOADING

            val list = DownloadConfig.dbHelper.queryByTaskTag(url)
            subTasks.clear()
            totalSize = 0
            completeSize = 0
            for (task in list) {
                task.listener = this
                totalSize += task.taskSize
                completeSize += task.completeSize
            }
            subTasks.addAll(list)

            if (subTasks.isEmpty()) {
                downloadNewTask()
            } else if (subTasks.size == threadNum) {
                existDownloadTask()
            } else {
                resetDownloadTask()
            }
        }
    }

    /**
     * 暂停下载任务
     */
    fun pauseDownload() {
        if (status != DownloadStatus.DOWNLOADING) {
            return
        }
        for (task in subTasks) {
            task.pause()
        }
        status = DownloadStatus.PAUSE
        listener.onPause()
    }

    /**
     *重置下载任务
     */
    fun resetDownloadTask() {
        mExecutorService.execute {
            for (task in subTasks) {
                DownloadConfig.dbHelper.delete(task)
            }
            subTasks.clear()
            downloadNewTask()
        }
    }

    private fun existDownloadTask() {
        startAsyncDownload()
    }

    private fun downloadNewTask() {
        mExecutorService.execute {
            listener.onStart()
            completeSize = 0
            val targetFile = File(savePath)
            val destinationFolder = File(targetFile.parent ?: "")
            if (!destinationFolder.exists()) {
                destinationFolder.mkdirs()
            }
            targetFile.createNewFile()

            val size = DownloadConfig.httpHelper.obtainTotalSize(url)
            totalSize = size
            val averageSize = size / threadNum
            for (i in 0 until threadNum) {
                var taskSize = averageSize
                if (i == (threadNum - 1)) {
                    taskSize += totalSize % threadNum
                }
                var start = 0L
                var index = i
                while (index > 0) {
                    start += subTasks[i - 1].taskSize
                    index--
                }
                val end = start + taskSize - 1
                val subTask =
                    SubDownloadTask(
                        url, taskSize, start,
                        end, start, targetFile, this
                    )
                subTasks.add(subTask)
                DownloadConfig.dbHelper.insert(subTask)
            }

            val file = RandomAccessFile(targetFile.absolutePath, "rwd")
            file.setLength(size)
            file.close()

            startAsyncDownload()
        }
    }

    private fun startAsyncDownload() {
        status = DownloadStatus.DOWNLOADING
        for (task in subTasks) {
            if (task.completeSize < task.taskSize) {
                mExecutorService.execute(task)
            }
        }
    }

    /**
     * 下载进度
     */
    override fun onDownloading(progress: Long, total: Long) {
        synchronized(this) {
            completeSize += progress
            listener.onDownloading(completeSize, totalSize)
        }
    }

    /**
     * 子任务完成回调
     * 此方法被将被调用threadNum次
     */
    override fun onComplete() {
        for (task in subTasks) {
            if (task.status != DownloadStatus.COMPLETED) {
                return
            }
        }
        Log.i(
            DownloadConfig.TAG, "${DownloadConfig.TAG}{${hashCode()}},下载完成 当前的线程名:${Thread.currentThread().name} "
        )
        for (task in subTasks) {
            DownloadConfig.dbHelper.delete(task)
        }
        status = DownloadStatus.COMPLETED
    }


    /**
     * 子任务出现异常的回调
     */
    override fun onError(msg: String) {
        //出现异常 暂停,清除任务重新下载
        pauseDownload()
        for (task in subTasks) {
            DownloadConfig.dbHelper.delete(task)
        }
        subTasks.clear()
        listener.onError(msg)
        listener.onCancel()
    }

}

使用方法

//引入依赖 gradle 7.0以下 项目根目录 build.gradle 文件
allprojects {
		repositories {
			...
			maven { url 'https://jitpack.io' }
		}
	}
//引入依赖 gradle 7.0以上 项目根目录 setting.gradle 文件
dependencyResolutionManagement {
    ...
    repositories {
    	...
        maven { url 'https://jitpack.io' }
    }
}
//模块module build.gradle
dependencies {
    ...
    implementation 'com.github.huangtaoOO.TaoComponent:lib-download:0.0.3'
}
//初始化,必须
DownloadConfig.setContext(application)

//构建下载任务
val downloadTask = DownloadTask(
            "https://dldir1.qq.com/qqfile/qq/TIM3.4.3/TIM3.4.3.22064.exe",
            file,
            object : DownloadListener {
                @SuppressLint("SetTextI18n")
                override fun onDownloading(progress: Long, total: Long) {
                    runOnUiThread {
                        tvProgress.text = "下载进度:${progress}/${total}"
                    }
                }
            })

//下载任务
downloadTask.download()

//重新下载
downloadTask.resetDownloadTask()

//暂停下载
downloadTask.pauseDownload()

源码传送门:github

  • 使用过程中如有BUG,请提issue
  • 使用过程中如有疑问或者更好的想法,欢迎进群讨论Android 学习交流群

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

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

相关文章

高效好用的开发工具箱——猿如意

目录 前言&#xff1a; 1.我常用的功能介绍 2.主要功能chatGPT测评 3.我的使用体验和改进建议 前言&#xff1a; 猿如意是一款帮助开发的效率工具&#xff0c;集成了许多有用的工具和文档教程。帮助开发者提升开发效率&#xff0c;帮你从“问题”找到“答案”。尤其是12月…

加注超融合 星辰天合推出 XHERE V2 系列

近日&#xff0c;北京星辰天合科技股份有限公司&#xff08;简称&#xff1a;“XSKY星辰天合”&#xff09;成功举办了主题为“能存会算&#xff0c;创新不断”的 XHERE 超融合新品渠道推介会&#xff0c;正式向市场推出 XHERE V2 超融合软件平台。通过继续加大对超融合产品线的…

上云节省 35%计算资源,420 个运维人天:运满满实时计算实践和思考

摘要&#xff1a;本文整理自满帮实时数据团队 TL 欧锐&#xff0c;在 FFA 2022 行业案例专场的分享。本篇内容主要分为四个部分&#xff1a;满帮业务及平台架构介绍实时数据实时产品未来计划Tips&#xff1a;点击「阅读原文」查看原文视频&演讲 ppt01满帮业务及平台架构介绍…

数据可视化系列-01大数据可视化基础

文章目录1.概述2.大数据可视化基础2.1 数据可视化基础知识1、数据可视化简史&#xff1a;2、数据可视化是什么&#xff1a;3、数据可视化的分类&#xff1a;4、数据可视化流程&#xff1a;5、数据可视化的意义&#xff1a;2.2 认识BI和数据可视化工具1、BI的简介&#xff1a;BI…

RabbitMQ:订阅模型-匹配模式

阅模型-匹配模式&#xff0c;相比于前两种订阅模型&#xff0c;是更细致的分组&#xff0c;允许 在RoutingKey 中使用匹配符 *&#xff1a;匹配一个单词#&#xff1a;匹配0个或多个单词 RabbitMQ 订阅模型-匹配&#xff08;topics&#xff09;模式主要有以下六个角色构成&#…

FFmpeg功能命令汇总

前言 如此强大的FFmpeg&#xff0c;能够实现视频采集、视频格式转化、视频截图、视频添加水印、视频切片、视频录制、视频推流、更改音视频参数功能等。通过终端命令如何实现这些功能&#xff0c;Richy在本文做一记录&#xff0c;以备之后查阅。 注意&#xff1a;下面一一列举…

AI4DB-Cardinality Estimation

一个学期已经结束了&#xff0c;通过这篇文章总结一下自己在CE方面所学习的内容。 由于大数据技术的飞速发展&#xff0c;使数据库在查询方面面临很大的挑战。原来数据量不大的时候&#xff0c;查询可以在很快的时间内得到结果&#xff0c;但是现在动辄就是上万上千万甚至上亿的…

数据结构之红黑树的生成、添加以及删除详解(附代码)

一&#xff0c;红黑树的来历 红黑树&#xff0c;首先是一个二叉树&#xff0c;对于二叉树&#xff0c;人们为了提升它的搜索效率&#xff0c;降低时间复杂度&#xff0c;创造出了二叉搜索树&#xff0c;把时间复杂度降低为对数级&#xff08;LOGn)&#xff0c;但是会出现一些极…

zookeeper之基本使用及实现分布式锁

写在前面 本文一起看下zk的基本用法。 安装 。 1&#xff1a;数据结构 采用类似于linux系统的文件系统存储结构&#xff0c;但不同于Linux系统文件&#xff0c;zk每个节点都可以存储数据&#xff0c;结构如下图&#xff1a; 节点类型分为如下四种&#xff1a; PERSISTENT&…

RK3568平台开发系列讲解(设备驱动篇)中断下文之tasklet的使用

🚀返回专栏总目录 文章目录 一、tasklet 的概念二、tasklet 参考步骤沉淀、分享、成长,让自己和他人都能有所收获!😄 📢我们一般将中断分为上下两个部分,分为上半部,下半部。上半部完成有严格时限的工作(必须),例如回复硬件等,这些工作都是在禁止其他中断情况下进…

汽车相关概念记录

目录 一、汽车电路相关概念 1.1、两个电源 1.2、单线制 1.3、低压直流供电 1.4、常电与ACC 1.4.1、大众ACC供电开关 1.4.2、奥迪ACC开关 二、电子电路 2.1、三极管 2.1.2、截止状态 2.1.3、放大区 2.1.4、饱和区 2.1.4、实例分析 一、汽车电路相关概念 1.1、两个电…

CIO40— 2022 行平常心,做自由人 (3年之约已满)

今天的天空依然很蓝。认识还是在那个不戴口罩的夏天。 感谢IT行业给了我们帮助。 IT将交流植根于微信群&#xff0c;微信群既是信息的集散地&#xff0c;也是良好实践的方案池。在工作中碰到的问题&#xff0c;只要在IT微信群中求助&#xff0c;大家都是知无不言&#xff0c…

C# 异步编程

一 异步编程 1 异步 asynchronize 2 主要解决的事情是 ① 等待一些耗时的任务&#xff08;特别是文件&#xff0c;网络操作&#xff09;而不阻塞当前任务&#xff1b; ② 异步编程提高响应能力&#xff08;特别是UI&#xff09; 开始一个任务后&#xff0c;让任务在离感应线…

机器学习:图文详细总结马尔科夫链及其性质(附例题分析)

目录0 写在前面1 从一个实例出发2 马尔科夫链3 马氏链的基本性质4 C-K方程5 平稳状态分布6 遍历性与例题分析0 写在前面 机器学习强基计划聚焦深度和广度&#xff0c;加深对机器学习模型的理解与应用。“深”在详细推导算法模型背后的数学原理&#xff1b;“广”在分析多个机器…

canopen4.0-canfestiva移植以及同步帧发送

1.canfestival移植入 工程包: 一、canfestival系列教程之程序移植 1.1、首先准备一个hal工程 ,cubmx --------------RCC配置 -----------SYS配置 ----------时钟配置 -----canopen定时器配置(开启中断) --------------can配置波特率,接收中断

CSS3知识点精学

CSS3 被拆分为"模块"。旧规范已拆分成小块&#xff0c;还增加了新的。 一些最重要 CSS3 模块如下&#xff1a; 选择器盒模型背景和边框文字特效2D/3D转换动画多列布局用户界面css引入方式 内嵌式&#xff1a;CSS写在style标签中&#xff0c;style标签虽然可以写在…

PE格式的base reloc分区

https://0xrick.github.io/win-internals/pe7/ 程序雕塑被编译之后&#xff0c;编译器假设可执行文件将会在特定1的v z基地址被加载&#xff0c;这个地址被保存在image_optional_header的imagebase成员中&#xff0c;一些地址会被计算出来然后硬编码到可执行文件中 出于各种原…

malmquist指数案例分析

传统的DEA模型可以反应静态的投入产出效率情况&#xff0c;但如果是面板数据&#xff0c;则需要使用malmquist指数进行研究。malmquist指数可以分析从t期到t1期的效率变化情况。Malmquist指数可分解为技术效率&#xff08;EC&#xff09;和技术进步&#xff08;TC&#xff09;&…

Java高手速成│Java程序怎样和数据库对话

从上一篇 Java高手速成│编写你第一个数据库程序 的例子中可以看出&#xff0c;Java和数据库的连接和对话离不开JDK库类&#xff0c;如java.sql包中支持数据库编程的各种API类、数据库软件DBMS、JDBC驱动软件或Java Connector以及你编写的数据库编程代码。 并且&#xff0c;在…

基础不牢,地动山摇系列 ------ 软硬通吃 unity常用API

&#x1f468;‍&#x1f4bb;个人主页&#xff1a;元宇宙-秩沅 hallo 欢迎 点赞&#x1f44d; 收藏⭐ 留言&#x1f4dd; 加关注✅! 本文由 秩沅 原创 收录于专栏 unity 实战系列 ⭐相关文章⭐ ——————————————————— -关于游戏剧情模式中用到的基础简单A…