踏破铁鞋无觅处,从AsyncTask学Android线程池

android对于主线程的响应时间限制的非常严格,稍有不慎就会遇到Application Not Responding(ANR)的弹框。用户可以轻点手指关掉你的APP。官方文档写的非常明确!同时,保持应用随时响应用户的操作也是良好用户体验的前提。

线程的开始和结束

要做到以上多线程是必不可少的。课本会告诉你什么时候开辟一个线程,但是很少说的一个很重要的问题是结束。比如,我现在在Activity里有一个工作需要创建一个线程执行,但是这个Activity在进入后台后不幸遇到系统回收资源被销毁了。但是这个线程还在漫无目的的游走,耗费资源。

如何结束?先创建一个:

mThread = Thread(Runnable {
    // do something here...
})

mThread?.start()

以上使用kotlin的lambda表达式简写了创建Runnable对象部分的代码。主旨还是创建了一个Runnable对象,并将其作为参数传入Thread

如何让一个Thread能够退出呢?这就要在Runnable身上下功夫了。首先添加一个是否停止的标识isCancelled,一旦值为true则停止线程的运行,否则继续。我们这里不讨论Thread#interrupt()这个方法,这个方法诡异的地方太多。

首先要给Runnable“添加一个属性”作为上文的是否停止的标识。直接添加时不可能的,Runnable只是一个interface,不是class。所以要实现这个借口为一个抽象类,这样就可以添加属性了。

abstract class CancelableRunnable() : Runnable {
    var isCancelled: Boolean = false
}

这里使用抽象类,是因为run()方法的实现留给使用的时候给出。

var runnable = object : CancelableRunnable() {
    override fun run() {
        if (isCancelled) {
            var msg = mHandler.obtainMessage(THREAD_CANCELLED)
            mHandler.sendMessage(msg)
            return
        }

        Thread.sleep(2000)

        if (isCancelled) {
            var msg = mHandler.obtainMessage(THREAD_CANCELLED)
            mHandler.sendMessage(msg)
            return
        }

        var msg = mHandler.obtainMessage(THREAD_FINISHED)
        mHandler.sendMessage(msg)
    }
}

Thread.sleep(2000)用来模拟一个费时的任务。开始之前检测是否取消了线程的执行,执行之后在检测。之后的检测是有的时候任务执行之后需要有持久化处理结果或者修改任务完成情况的标识之类的动作,如果已经取消了线程的执行,即使任务执行完成也不持久化结果、不修改完成情况。

最后都检测完成之后如果没有取消线程,则发出任务完成执行的消息。

发出和处理这些消息的Handler的定义:

var mHandler = object : Handler() {
    override fun handleMessage(msg: Message?) {
        when (msg?.what) {
            THREAD_CANCELLED -> {
                mResultTextView.text = "thread cancelled"
            }
            THREAD_FINISHED -> {
                mResultTextView.text = "thread finished"
            }
            else -> {
                mResultTextView.text = "what's going on"
            }
        }
    }
}

运行在UI线程的Handler检测从线程发出的消息,如果是THREAD_CANCELLED那就是线程已经取消了,如果是THREAD_FINISHED那就是线程完全运行结束。之后根据message的消息设置TextView的文本内容。

这里使用了两个按钮来启动和停止线程:

findViewById(R.id.start_button)?.setOnClickListener { v ->
    runnable.isCancelled = false
    mThread = Thread(runnable)
    mThread?.start()

    mResultTextView.text = "Thread running..."
}

findViewById(R.id.stop_button)?.setOnClickListener { v ->
    this.runnable.isCancelled = true
}

上面用到的Runnable是只做一件事的,如果是连续不断的循环很多事的话也可以使用white语句来控制是否一直执行线程的工作。一旦设置为停止线程,则停止线程任务的循环跳出Runnable#run()方法,结束线程。

完整代码放在附录中
所以,如果你在Activity里开辟了一个线程,在Activity被回收的时候结束线程就可以这么做:

override fun onDestroy() {
    super.onDestroy()
    
    this.runnable.isCancelled = true
}

这样就再也不用担心Activity挂了,线程还阴魂不散了。

AsyncTask

既然缘起AsyncTask那就肯定需要读者一起了解一下相关的概念。

比起来使用Handler+Thread+Runnable的多线程异步执行模式来说,使用AsyncTask是简单了非常的多的。

先简单了解一下AsyncTask

public abstract class AsyncTask<Params, Progress, Result>

AsyncTask是一个抽象泛型类。三个类型ParamsProgressResult分别对应的是输入参数的类型,精度更新使用的类型,最后是返回结果的类型。其中任何一个类型如果你不需要的话,可以使用java.lang.Void代替。

继承AsyncTask给出自己的实现,最少需要实现doInBackground方法。doInBackground方法是在后台线程中运行的。如果要在任务执行之后更新UI线程的话还至少需要给出onPostExecute方法的实现,在这个方法中才可以更新UI。

上述的两个方法已经构成了一个AsyncTask使用的基本单元。在后台线程处理一些任务,并在处理完成之后更新UI。但是如果一个任务比较长,只是在最后更新UI是不够的,还需要不断的提示用户已经完成的进度是多少。这就是需要另外实现onProgressUpdate方法。并在doInBackground方法中调用publishProgress方法发出每个任务的处理进度。

这个AsyncTask总体上就是这样的了:

inner class DemoAsyncTask() : AsyncTask<String, Int, String>() {
    //        var isRunning = true
    override fun doInBackground(vararg params: String?): String? {
        Log.i(TAG, "##AsyncTask doing something...")
        
        var i = 0
        val TOTAL = 100000000
        var progress = 0
        while (i < TOTAL) {
            Log.d(TAG, "doning jobs $i is cancelled $isCancelled")
            i++

            var currentProgress = i.toFloat() / TOTAL
            if (currentProgress > progress && Math.abs(currentProgress - progress) > 0.1) {
                progress = currentProgress
                publishProgress((progress * 100).toInt())
            }
        }
        }

        Log.d(TAG, "doing jobs $i is cancelled $isCancelled")

        return "Task done"
    }

    override fun onPostExecute(result: String?) {
        this@CancalableActivity.mAsyncTextView?.text = result
    }

    override fun onProgressUpdate(vararg values: Int?) {
        mAsyncTextView?.text = "${mAsyncTextView?.text ?: "Async task..."} progress: ${values?.get(0) ?: 0}"
    }
}

到这里各位读者应该对AsyncTask已经有一个总体的认识了。后台任务在doInBackground处理,处理过程的百分比使用publishProgress方法通知,并在onProgressUpdate方法中更新UI的百分比。最后任务处理全部完成之后在onPostExecute更新UI,显示全部完成。

怎么取消一个任务的执行呢?这个机本身还上面的线程的取消基本上一样。只是AsyncTask已经提供了足够的属性和方法完成取消的工作。直接调用AsyncTask#cancel方法就可以发出取消的信号,但是是否可以取消还要看这个方法的返回值是什么。如果是true那就是可以,否则任务不可取消(但是不可取消的原因很可能是任务已经执行完了)。

调用cancel方法发出取消信号,并且可以取消的时候。isCancelled()就会返回true。同时onPostExecute这个方法就不会再被调用了。而是onCancelled(object)方法被调用。同样是在doInBackground这个方法执行完之后调用。所以,如果想要在取消任务执行后尽快的调用到onCancelled(object)的话,就需要在onInBackground的时候不断的检查isCancelled()是否返回true。如果返回的是true就跳出方法的执行。

inner class DemoAsyncTask() : AsyncTask<String, Int, String>() {
    //        var isRunning = true
    override fun doInBackground(vararg params: String?): String? {
        Log.i(TAG, "##AsyncTask doing something...")

        var i = 0
        val TOTAL = 1000000
        var progress = 0.0f
        while (i < TOTAL && !isCancelled) {
            Log.d(TAG, "doning jobs $i is cancelled $isCancelled")
            i++

            var currentProgress = i.toFloat() / TOTAL
            if (currentProgress > progress && Math.abs(currentProgress - progress) > 0.1) {
                progress = currentProgress
                publishProgress((progress * 100).toInt())
            }
        }

        Log.d(TAG, "doning jobs $i is cancelled $isCancelled")

        return "Task done"
    }

    override fun onPostExecute(result: String?) {
        this@CancalableActivity.mAsyncTextView?.text = result
    }

    override fun onProgressUpdate(vararg values: Int?) {
        mAsyncTextView?.text = "${mAsyncTextView?.text ?: "Async task..."} progress: ${values?.get(0) ?: 0}"
    }

    override fun onCancelled() {
        Log.i(TAG, "##Task cancelled")
//            isRunning = false
        this@CancalableActivity.mAsyncTextView?.text = "###Task cancelled"
    }

//        override fun onCancelled(result: String?) {
//            Log.i(TAG, "##Task cancelled")
////            isRunning = false
//            this@CancalableActivity.mAsyncTextView?.text = result ?: "Task cancelled"
//        }
}

onCancelled()是API level 3的时候加入的。onCancelled(Result result)是API level 11的时候加入的。这个在兼容低版本的时候需要注意。

但是一点需要格外注意:

AsyncTask一定要在UI线程初始化。不过在**JELLY_BEAN**以后这个问题也解决了。
总之,在UI线程初始化你的`AsyncTask`肯定是不会错的。

线程池

下面就来看看线程池的概念。顾名思义,线程池就是放线程的池子。把费时费力,或者影响响应用户操作的代码放在另外一个线程执行时常有的事。但是如果无顾忌的开辟线程,却会适得其反,严重的浪费系统资源。于是就有了线程池。线程池就是通过某些机制让线程不要创建那么多,能复用就复用,实在不行就让任务排队等一等

这个机制在线程池的构造函数里体现的非常明显:

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler)
  • corePoolSize 线程池里闲着也不回收的线程数量。除非allowCoreThreadTimeOut指定可以回收。
  • ** maximumPoolSize** 线程池允许的最大线程数。
  • ** keepAliveTime** 非核心线程(就是如果核心线程数量corePoolSize定义为1的话,第二个就是非核心线程)的超时时间。
  • unit keepAliveTime的时间单位,毫秒,秒等。
  • ** workQueue** 存放execute(Runnable cmd)方法提交的Runnable任务。
  • ** threadFactory**线程池用来创建新线程用的一个工厂类。
  • ** handler**线程池达到最大线程数,并且任务队列也已经满的时候会拒绝execute(Runnable cmd)方法提交任务。这个时候调用这个handler。

知道以上基本内容以后,就可以探讨线程池管理线程的机制了。概括起来有三点:

  1. 如果线程池的线程数量少于corePoolSize的时候,线程池会使用threadFactory这个线程工厂创建新的线程执行Runnable任务。
  2. 如果线程池的线程数量大于corePoolSize的时候,线程池会把Runnable任务存放在队列workQueue中。
  3. 线程池的线程数量大于corePoolSize,队列workQueue已满,而且小于maximumPoolSize的时候,线程池会创建新的线程执行Runnable任务。否则,任务被拒。

现在回到AsyncTask。被人广为诟病的AsyncTask是他的任务都是顺序执行的。一个AsyncTask的实例只能处理一个任务。但是在AsyncTask后面处理任务的是一个静态的线程池。在看这个线程池SerialExecutorexecute方法实现:

final ArrayDeque<Runnable> mTasks = new ArrayDeque<Runnable>();

public synchronized void execute(final Runnable r) {
    mTasks.offer(new Runnable() {
        public void run() {
            try {
                r.run();
            } finally {
                scheduleNext();
            }
        }
    });
    if (mActive == null) {
        scheduleNext();
    }
}

protected synchronized void scheduleNext() {
    if ((mActive = mTasks.poll()) != null) {
        // 执行一个task
    }
}

这个线程池SerialExecutor在处理Runnable的传入参数的时候对这个任务进行了重新包装成了一个新的Runnable对象,并且将这个新的对象存入了一个叫做mTasks的队列。这个新的Runnable对象首先执行传入的任务,之后不管有无异常调用scheduleNext方法执行下一个。于是整体的就生成了一个传入的任务都顺序执行的逻辑。

这个线性执行的静态线程池SerialExecutor的实现非常简单。并不涉及到我们前文所说的那么多复杂的内容。在实现上,这个线程池只实现了线程池的最顶层接口Executor。这个接口只有一个方法就是execute(Runnable r)。另外需要强调一点:mTasks的类型ArrayDeque<T>是一个不受大小限制的队列。可以存放任意多的任务。在线程池的讨论中遇到队列就需要看看容量概念。

SerialExecutor只是进行了简单的队列排列。但是在scheduleNext方法的实现上又会用到一个复杂一些的线程池来执行任务的具体执行。这线程池叫做
THREAD_POOL_EXECUTOR。我们来具体看看其实现:

private static final int CPU_COUNT = Runtime.getRuntime().availableProcessors();
private static final int CORE_POOL_SIZE = CPU_COUNT + 1;
private static final int MAXIMUM_POOL_SIZE = CPU_COUNT * 2 + 1;
private static final int KEEP_ALIVE = 1;

public static final Executor THREAD_POOL_EXECUTOR
        = new ThreadPoolExecutor(CORE_POOL_SIZE, MAXIMUM_POOL_SIZE, KEEP_ALIVE,
                TimeUnit.SECONDS, sPoolWorkQueue, sThreadFactory);

这个线程池的实现非常具有现实价值。虽然稍后介绍的系统提供的几种线程池的实现就够用。但是难免遇到一些需要自定义线程池的情况。详细解析如下:

  • CORE_POOL_SIZE 线程池的核心线程数量为设备核心数加一。
  • ** MAXIMUM_POOL_SIZE** 线程池的最大线程数量为核心数的两倍加一。
  • ** KEEP_ALIVE** 线程池中非核心线程的超时时间为一秒。
  • ** sPoolWorkQueue ** 线程池存放任务的队列。最大个数为128个。参考上面说的线程池处理机制,会出现任务被拒的情况。排队的线程池SerialExecutor存放任务的队列是可以认为无限长的,但是THREAD_POOL_EXECUTOR的队列最多存放128个任务,加上线程池核心线程的数量,能处理的任务相对有限。出现任务被拒的情况的几率比较大。所以,往AsyncTask里直接添加Runnable对象的时候需要三思。
  • ** sThreadFactory** 线程池用来创建线程的工厂对象。ThreadFactory是一个只有一个方法Thread newThread(Runnable r);的接口。这里在实现的时候给新创建的线程添加了一个原子计数,并把这个计数作为线程名称传递给了线程的构造函数。

到这里,我们就已经很清楚AsyncTask是如何用一个极其简单的线程池SerialExecutor给任务排队的。又是如何使用一个复杂一些的线程池THREAD_POOL_EXECUTOR来处理具体的任务执行的。尤其是线程池THREAD_POOL_EXECUTOR,在我们实际应用一个自定义的线程池的时候在设定线程池核心线程数量,线程池最大线程数量的时候都依据什么?明显就是设备的CPU核心数。线程分别在不同个CPU核心中做并行的处理。核心数多可以同时处理的线程数就相对较多,相反则会比较少一些。如此设置核心线程数量就会平衡并行处理的任务数量和在处理的过程中耗费的系统资源。

为了让开发者省时省力,系统默认的提供了四种可以适应不同应用条件的线程池:

public class Executors {
    public static ExecutorService newFixedThreadPool(int nThreads) {
        return new ThreadPoolExecutor(nThreads, nThreads,
                                      0L, TimeUnit.MILLISECONDS,
                                      new LinkedBlockingQueue<Runnable>());
    }
    
    public static ExecutorService newSingleThreadExecutor() {
        return new FinalizableDelegatedExecutorService
            (new ThreadPoolExecutor(1, 1,
                                    0L, TimeUnit.MILLISECONDS,
                                    new LinkedBlockingQueue<Runnable>()));
    }
    
    public static ExecutorService newCachedThreadPool() {
        return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                      60L, TimeUnit.SECONDS,
                                      new SynchronousQueue<Runnable>());
    }
    
    public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
        return new ScheduledThreadPoolExecutor(corePoolSize);
    }
}
  • ** newFixedThreadPool** 顾名思义,线程数量固定的线程池,且其数量等于参数指定值。这一类型的线程池的核心线程数量和最大线程数量是一样的。存放任务的队列的容量可以被认为无限大。一旦线程池创建的线程数量等* nThreads*参数值的时候,新增的任务将会被存放在任务队列中等待核心线程可用的时候执行。
  • ** newSingleThreadExecutor** newFixedThreadPool的一个特殊情况,当mThreads值为1的时候。
  • ** newCachedThreadPool** 这一类型的线程池中创建的线程都有60秒的超时时间,由于超时时间比较长等于是线程空闲了以后被缓存了60秒。由于核心线程数量为0,所以创建的线程都是非核心线程。也因此超时时间才管用。任务队列SynchronousQueue非常特殊,简单理解就是一个任务都存放不了。而线程池的最大线程数量又设定为Integer.MAX_VALUE,可以认为是无限大。根据线程池处理任务的机制,可以认为有新任务过来就会创建一个线程去处理这个任务,但是如果存在空闲没有超时的线程会优先使用。
  • ** newScheduledThreadPool** 生成一个ScheduledThreadPoolExecutor实例。可以通过其提供的接口方法设定延迟一定的时间执行或者隔一定的时间周期执行。

来一个例子:

import static java.util.concurrent.TimeUnit.*;
class BeeperControl {
    private final ScheduledExecutorService scheduler =
    Executors.newScheduledThreadPool(1);

    public void beepForAnHour() {
        final Runnable beeper = new Runnable() {
            public void run() { System.out.println("beep"); 
        };
        final ScheduledFuture beeperHandle =
            scheduler.scheduleAtFixedRate(beeper, 10, 10, SECONDS);
        scheduler.schedule(new Runnable() {
            public void run() { beeperHandle.cancel(true); }
        }, 60 * 60, SECONDS);
    }
}}

附录

这里是上面例子中使用的全部代码。

线程的停止:

package demo.retrofit2rxjavademo.Activities

import android.os.Bundle
import android.os.Handler
import android.os.Message
import android.support.v7.app.AppCompatActivity
import android.widget.TextView
import demo.retrofit2rxjavademo.R

class CancalableActivity : AppCompatActivity() {
    lateinit var mResultTextView: TextView
    var mHandler = object : Handler() {
        override fun handleMessage(msg: Message?) {
            when (msg?.what) {
                THREAD_CANCELLED -> {
                    mResultTextView.text = "thread cancelled"
                }
                THREAD_FINISHED -> {
                    mResultTextView.text = "thread finished"
                }
                else -> {
                    mResultTextView.text = "what's going on"
                }
            }
        }
    }

    var mThread: Thread? = null

    var runnable = object : CancelableRunnable() {
        override fun run() {
            if (isCancelled) {
                var msg = mHandler.obtainMessage(THREAD_CANCELLED)
                mHandler.sendMessage(msg)
                return
            }

            Thread.sleep(2000)

            if (isCancelled) {
                var msg = mHandler.obtainMessage(THREAD_CANCELLED)
                mHandler.sendMessage(msg)
                return
            }

            var msg = mHandler.obtainMessage(THREAD_FINISHED)
            mHandler.sendMessage(msg)
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_cancalable)

        mResultTextView = findViewById(R.id.run_result_text_view) as TextView

        findViewById(R.id.start_button)?.setOnClickListener { v ->
            runnable.isCancelled = false
            mThread = Thread(runnable)
            mThread?.start()

            mResultTextView.text = "Thread running..."
        }

        findViewById(R.id.stop_button)?.setOnClickListener { v ->
            this.runnable.isCancelled = true
        }
    }

    abstract class CancelableRunnable() : Runnable {
        var isCancelled: Boolean = false
    }

    companion object {
        val THREAD_FINISHED = 0
        val THREAD_CANCELLED = 1
    }
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容

  • 原文链接:http://blog.csdn.net/u010687392/article/details/4985...
    xpengb阅读 1,221评论 0 1
  • 前段时间遇到这样一个问题,有人问微信朋友圈的上传图片的功能怎么做才能让用户的等待时间较短,比如说一下上传9张图片,...
    加油码农阅读 1,141评论 0 2
  • Android中的线程 线程,在Android中是非常重要的,主线程处理UI界面,子线程处理耗时操作。如果在主线程...
    shenhuniurou阅读 731评论 0 3
  • 从用途上来说,线程分为主线程和子线程,主线程主要处理和界面相关的事情,子线程则往往用于执行耗时操作。 除了Thre...
    小柏不是大白阅读 594评论 0 3
  • 20170324函数的参数 定义函数的时候,我们把参数的名字和位置确定下来,函数的接口定义就完成了。对于函数的调用...
    田旭1阅读 181评论 0 0