Android 完整版的下載示例

童思宇發表於2018-02-08

首先我們需要將專案中會使用到的依賴庫新增好,編輯app/build.gradle檔案,在dependencies閉包中新增如下內容:

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation 'com.android.support:appcompat-v7:26.1.0'
    implementation 'com.android.support.constraint:constraint-layout:1.0.2'
    testImplementation 'junit:junit:4.12'
    androidTestImplementation 'com.android.support.test:runner:1.0.1'
    androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.1'
    implementation 'com.squareup.okhttp3:okhttp:3.9.1'
}

這裡只需新增一個OkHttp的依賴就行了,待會兒在編寫網路相關的功能時,我們將使用OkHttp來進行實現.

接下來需要定義一個回撥介面,用於對下載過程中的各種狀態進行監聽和回撥,新建一個DownloadListener介面,程式碼如下:

public interface DownloadListener {
    
    void onProgress(int progress);
    
    void onSuccess();
    
    void onFailed();
    
    void onPaused();
    
    void onCanceled();
}

可以看到,這裡我們一共定義了5個回撥方法,onProgress()方法用於通知當前的下載進度,onSuccess()方法用於通知下載成功事件,onFailed()方法用於通知下載失敗事件,onPaused()方法用於通知下載暫停事件,onCanceled()方法用於通知下載取消事件.

新建一個DownloadTask繼承自AsyncTask,程式碼如下:

public class DownloadTask extends AsyncTask<String, Integer, Integer> {

    public static final int TYPE_SUCCESS = 0;
    public static final int TYPE_FAILED = 1;
    public static final int TYPE_PAUSED = 2;
    public static final int TYPE_CANCELED = 3;

    private DownloadListener mListener;

    private boolean isCanceled = false;

    private boolean isPaused = false;

    private int lastProgress;

    public DownloadTask(DownloadListener listener) {
        this.mListener = listener;
    }

    @Override
    protected Integer doInBackground(String... params) {
        InputStream is = null;
        RandomAccessFile savedFile = null;
        File file = null;
        try {

            long downloadedLength = 0;//記錄已下載的檔案長度
            String downloadUrl = params[0];
            String fileName = downloadUrl.substring(downloadUrl.lastIndexOf("/"));
            String directory = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).getPath();
            file = new File(directory + fileName);
            if (file.exists()) {
                downloadedLength = file.length();
            }
            long contentLength = getContentLength(downloadUrl);
            if (contentLength == 0) {
                return TYPE_FAILED;
            } else if (contentLength == downloadedLength) {
                //一下載位元組和檔案總位元組相等,說明已經下載完成了
                return TYPE_SUCCESS;
            }
            OkHttpClient client = new OkHttpClient();
            Request request = new Request.Builder()
                    //斷點下載,指定從哪個位元組開始下載
                    .addHeader("RANGE", "bytes=" + downloadedLength + "-")
                    .url(downloadUrl)
                    .build();
            Response response = client.newCall(request).execute();
            if (response != null) {
                is = response.body().byteStream();
                savedFile = new RandomAccessFile(file, "rw");
                savedFile.seek(downloadedLength);//跳過已下載的位元組
                byte[] b = new byte[1024];
                int total = 0;
                int len;
                while ((len = is.read(b)) != -1) {
                    if (isCanceled) {
                        return TYPE_CANCELED;
                    } else if (isPaused) {
                        return TYPE_PAUSED;
                    } else {
                        total += len;
                        savedFile.write(b, 0, len);
                        //計算已下載的百分比
                        int progress = (int) ((total + downloadedLength) * 100 / contentLength);
                        publishProgress(progress);
                    }
                }
                response.body().close();
                return TYPE_SUCCESS;
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            try {
                if (is != null) {
                    is.close();
                }
                if (savedFile != null) {
                    savedFile.close();
                }
                if (isCanceled && file != null) {
                    file.delete();
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        return TYPE_FAILED;
    }

    @Override
    protected void onProgressUpdate(Integer... values) {
        int progress = values[0];
        if (progress > lastProgress) {
            mListener.onProgress(progress);
            lastProgress = progress;
        }
    }

    @Override
    protected void onPostExecute(Integer status) {
        switch (status) {
            case TYPE_SUCCESS:
                mListener.onSuccess();
                break;
            case TYPE_FAILED:
                mListener.onFailed();
                break;
            case TYPE_PAUSED:
                mListener.onPaused();
                break;
            case TYPE_CANCELED:
                mListener.onCanceled();
            default:
                break;
        }
    }
    
    public void pauseDownload(){
        isPaused = true;
    }
    
    public void cancelDownload(){
        isCanceled = true;
    }

    private long getContentLength(String downloadUrl) throws IOException {

        OkHttpClient client = new OkHttpClient();
        Request request = new Request.Builder()
                .url(downloadUrl)
                .build();
        Response response = client.newCall(request).execute();
        if (response != null && response.isSuccessful()) {
            long contentLength = response.body().contentLength();
            response.close();
            return contentLength;
        }
        return 0;
    }
}

這段程式碼比較長,我們需要一步步進行分析,首先看一下AsyncTask中的3個泛型引數,第一個泛型引數指定為String,表示在執行AsyncTask的時候需要傳入一個字串引數給後臺任務,第二個泛型引數指定為Integer,表示使用整型資料來作為進度顯示單位,第三個泛型引數指定為Integer,則表示使用整型資料來反饋執行結果.

接下來定義了4個整型常量用於表示下載的狀態,TYPE_SUCCESS表示下載成功,TYPE_FAILED表示下載失敗,TYPE_PAUSED表示暫停下載,TYPE_CANCELED表示取消下載,然後在DownloadTask的建構函式中要求傳入一個剛剛定義的DownloadListener引數,我們待會就會將下載的狀態通過這個引數進行回撥.

接著就是要重寫doInBackgound(),onProgressUpdate()和onPostExecute()這3個方法了,doInBackgound()方法用於在後臺執行具體的下載邏輯,onProgressUpdate()方法用於在介面上更新當前的下載進度,onPostExecute()方法通知最終的下載結果.

那麼先來看一下doInBackgound()方法,首先我們從引數中獲取到了下載的URL地址,並根據URL地址解析出了下載的檔名,然後指定將檔案下載到Environment.DIRECTORY_DOWNLOADS目錄下,也就是SD卡的Download目錄,我們還要判斷一下Download目錄中是不是已經存在要下載的檔案了,如果已經存在的話則讀取已下載的位元組數,這樣就可以在後面啟用斷點續傳的功能,接下來先是呼叫了getContentLength()方法來獲取待下載檔案的總長度,如果檔案長度等於0則說明檔案有問題,直接返回TYPE_FAILED,如果檔案長度等於已下載檔案長度,那麼說明檔案已經下載完了,直接返回TYPE_SUCCESS即可,緊接著使用OkHttp來傳送一條網路請求,需要注意的是,這裡在請求中新增了一個header,用於告訴伺服器我們想要從哪個位元組開始下載,因為已下載過的部分就不需要再重新下載了,接下來讀取伺服器相應的資料,並使用Java的檔案流的方法,不斷從網路上讀取資料,不斷寫入到本地,一直到檔案全部下載完成為止,在這個過程中,我們還要判斷使用者有沒有觸發暫停或者取消的操作,如果有的話則返回TYPE_PAUSED或TYPE_CANCELED來中斷下載,如果沒有的話則實時計算當前的下載進度,然後呼叫publishProgress()方法進行通知,暫停和取消操作都是使用一個布林型的變數來進行控制的,呼叫pauseDownload()或cancelDownload()方法即可更改變數的值.

接下來看一下onProgressUpdate()方法,也非常簡單,就是根據引數中傳入的下載狀態來進行回撥,下載成功就呼叫DownloadListener的onSuccess()方法,下載失敗就呼叫onFailed()方法,暫停下載就呼叫onPaused()方法,取消下載就呼叫onCanceled()方法.

這樣就把具體的下載功能完成了,下面為了保證DownloadTask可以一直在後臺執行,我們還需要建立一個下載的服務,新建DownloadService,程式碼如下:

public class DownloadService extends Service {

    private DownloadTask downloadTask;

    private String downloadUrl;

    private DownloadListener listener = new DownloadListener() {
        @Override
        public void onProgress(int progress) {
            getNotificationManager().notify(1,getNotification("Downloading...",progress));
        }

        @Override
        public void onSuccess() {
            downloadTask = null;
            //下載成功時將前臺服務通知關閉,並建立一個下載成功的通知
            stopForeground(true);
            getNotificationManager().notify(1,getNotification("Download Success",-1));
            Toast.makeText(DownloadService.this,"Download Success",Toast.LENGTH_SHORT).show();
        }

        @Override
        public void onFailed() {
            downloadTask = null;
            //下載失敗時將前臺服務通知關閉,並建立一個下載失敗的通知
            stopForeground(true);
            getNotificationManager().notify(1,getNotification("Download Failed",-1));
            Toast.makeText(DownloadService.this,"Download Failed",Toast.LENGTH_SHORT).show();
        }

        @Override
        public void onPaused() {
            downloadTask = null;
            Toast.makeText(DownloadService.this,"Paused",Toast.LENGTH_SHORT).show();
        }

        @Override
        public void onCanceled() {
            downloadTask = null;
            stopForeground(true);
            Toast.makeText(DownloadService.this,"Canceled",Toast.LENGTH_SHORT).show();
        }
    };

    private DownloadBinder mBinder = new DownloadBinder();

    @Nullable
    @Override
    public IBinder onBind(Intent intent) {
        return mBinder;
    }

    class DownloadBinder extends Binder{

        public void startDownload(String url){
            if (downloadTask == null){
                downloadUrl = url;
                downloadTask = new DownloadTask(listener);
                downloadTask.equals(downloadUrl);
                startForeground(1,getNotification("Downloading",0));
                Toast.makeText(DownloadService.this,"Downloading...",Toast.LENGTH_SHORT).show();
            }
        }

        public void pauseDownload(){
            if (downloadTask != null){
                downloadTask.pauseDownload();
            }
        }

        public void cancelDownload(){
            if (downloadTask != null){
                downloadTask.cancelDownload();
            }else {
                if (downloadUrl != null){
                    //取消下載時需將檔案刪除,並將通知關閉
                    String fileName = downloadUrl.substring(downloadUrl.lastIndexOf("/"));
                    String directory = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).getPath();
                    File file = new File(directory + fileName);
                    if (file.exists()){
                        file.delete();
                    }
                    getNotificationManager().cancel(1);
                    stopForeground(true);
                    Toast.makeText(DownloadService.this,"Canceled",Toast.LENGTH_SHORT).show();
                }
            }
        }
    }
    
    private NotificationManager getNotificationManager(){
        return (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
    }
    
    private Notification getNotification(String title,int progress){
        Intent intent = new Intent(this,MainActivity.class);
        PendingIntent pi = PendingIntent.getActivity(this,0,intent,0);
        NotificationCompat.Builder builder = new NotificationCompat.Builder(this);
        builder.setSmallIcon(R.mipmap.ic_launcher);
        builder.setLargeIcon(BitmapFactory.decodeResource(getResources(),R.mipmap.ic_launcher));
        builder.setContentIntent(pi);
        builder.setContentTitle(title);
        if (progress > 0){
            //progress大於或等於0時才需要下載進度
            builder.setContentText(progress + "%");
            builder.setProgress(100,progress,false);
        }
        return builder.build();
    }
}

這段程式碼同樣也比較長,我們還是得耐心慢慢看,首先這裡建立了一個DownloadListener的匿名類例項,並在匿名類中去實現了onProgress(),onSuccess(),onFailed(),onPaused()和onCanceled()這5個方法,在onProgress()方法中,我們呼叫了getNotification()方法構建了一個用於顯示下載進度的通知,然後呼叫NotificationManager的notify()方法去觸發這個通知,這樣就可以在下拉狀態列實時看到當前下載的進度了,在onSuccess()方法中,我們首先是將正在下載的前臺通知關閉,然後建立一個新的通知用於告訴使用者下載成功了,其他幾個方法也都是類似的,分別用於告訴使用者下載失敗,暫停和取消這幾個事件.

接下來為了要讓DownloadService可以和活動進行通訊,我們又建立了一個DownloadBinder,DownloadBinder中提供了startDownload(),pauseDownload()和cancelDownload()這3個方法,那麼顧名思義,它們分別是用於開始下載,暫停下載和取消下載的,在startDownload()方法中,我們建立了一個DownloadTask的例項,把剛才的Down作為引數傳入,然後呼叫execute()方法開啟下載,並將下載檔案的URL地址傳入到execute()方法中,同時,為了讓這個下載服務成為一個前臺服務,我們還呼叫了startForeground()方法,這樣就會在系統狀態列中建立一個持續執行的通知了,接著往下看,pauseDownload()方法中的程式碼就非常簡單了,就是簡單的呼叫了一下DownloadTask中的pauseDownload()方法,cancelDownload()方法中的邏輯也基本類似,但是要注意,取消下載的時候我們需要將正在下載的檔案刪除掉,這一點和暫停下載是不同的.

另外,DownloadService類中所有使用到的通知都是呼叫getNotification()方法進行構建的,其中setProgress()方法接收3個引數,第一個引數傳入通知的最大進度,第二個引數傳入通知的當前進度,第三個參數列示是否使用模糊進度條,這裡傳入false,設定完setProgress()方法,通知上就會有進度條顯示出來了.

現在下載的服務已經成功實現了,後端的工作基本都完成了,那麼接下來我們開始編寫前端的部分了,activity_main.xml中的程式碼.如下:

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout 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"
    tools:context="com.example.servicebestpractice.MainActivity">

    <Button
        android:id="@+id/start_download"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Start Download"
        android:textAllCaps="false"/>
    
    <Button
        android:id="@+id/pause_download"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Pause Download"
        android:textAllCaps="false"
        app:layout_constraintTop_toBottomOf="@id/start_download"/>
    
    <Button
        android:id="@+id/cancel_download"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Cancel Download"
        android:textAllCaps="false"
        app:layout_constraintTop_toBottomOf="@id/pause_download"/>

</android.support.constraint.ConstraintLayout>

佈局檔案還是非常簡單的,這裡放置了3個按鈕,分別用於開始下載,暫停下載和取消下澡.

MainActivity.java中的程式碼,如下:

public class MainActivity extends AppCompatActivity implements View.OnClickListener{

    private DownloadService.DownloadBinder downloadBinder;

    private ServiceConnection connection = new ServiceConnection() {
        @Override
        public void onServiceConnected(ComponentName componentName, IBinder service) {
            downloadBinder = (DownloadService.DownloadBinder) service;
        }

        @Override
        public void onServiceDisconnected(ComponentName componentName) {

        }
    };

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        Button startDownload = findViewById(R.id.start_download);
        Button pauseDownload = findViewById(R.id.pause_download);
        Button cancelDownload = findViewById(R.id.cancel_download);
        startDownload.setOnClickListener(this);
        pauseDownload.setOnClickListener(this);
        cancelDownload.setOnClickListener(this);
        Intent intent = new Intent(this,DownloadService.class);
        startService(intent);
        bindService(intent,connection,BIND_AUTO_CREATE);//繫結服務
        if (ContextCompat.checkSelfPermission(MainActivity.this, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED){
            ActivityCompat.requestPermissions(MainActivity.this,new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE},1);
        }
    }

    @Override
    public void onClick(View view) {
        if (downloadBinder == null){
            return;
        }
        switch (view.getId()){
            case R.id.start_download:
                String url = "https://image.baidu.com/search/detail?ct=503316480&z=0&ipn=d&word=%E8%BE%B9%E7%89%A7&step_word=&hs=0&pn=2&spn=0&di=49639799020&pi=0&rn=1&tn=baiduimagedetail&is=0%2C0&istype=0&ie=utf-8&oe=utf-8&in=&cl=2&lm=-1&st=undefined&cs=3763498388%2C2861370255&os=1617117754%2C3440027732&simid=0%2C0&adpicid=0&lpn=0&ln=1991&fr=&fmq=1518064065581_R&fm=&ic=undefined&s=undefined&se=&sme=&tab=0&width=undefined&height=undefined&face=undefined&ist=&jit=&cg=&bdtype=13&oriquery=&objurl=http%3A%2F%2Fimgsrc.baidu.com%2Fimage%2Fc0%3Dpixel_huitu%2C0%2C0%2C294%2C40%2Fsign%3D3f7840f1114c510fbac9ea5a09214041%2F96dda144ad3459820a8b7bab07f431adcbef8482.jpg&fromurl=ippr_z2C%24qAzdH3FAzdH3Fooo_z%26e3Bi7tp7_z%26e3Bv54AzdH3Fri5p5AzdH3Ffi5oAzdH3Fda8ma8abAzdH3F8m89d9dcmn9a_z%26e3Bip4s&gsm=0&rpstart=0&rpnum=0";
                downloadBinder.startDownload(url);
                break;
            case R.id.pause_download:
                downloadBinder.pauseDownload();
                break;
            case R.id.cancel_download:
                downloadBinder.cancelDownload();
                break;
                default:
                    break;
        }
    }

    @Override
    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
        switch (requestCode){
            case 1:
                if (grantResults.length >0 && grantResults[0] != PackageManager.PERMISSION_GRANTED){
                    Toast.makeText(this,"拒絕許可權將無法使用程式",Toast.LENGTH_SHORT).show();
                    finish();
                }
                break;
                default:
                    break;
        }
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        unbindService(connection);
    }
}

可以看到,這裡我們首先建立了一個ServiceConnection的匿名類,然後在onServiceConnected()方法中獲取到DownloadBinder的例項,有了這個例項,我們就可以在活動中呼叫服務提供的各種方法了.

接下來看一下onCreate()方法,在這裡我們對各個按鈕進行了初始化操作並設定了點選事件,然後分別呼叫了StartService()和bindService()方法來啟動和繫結服務,這一點是至關重要的,因為啟動服務可以保證DownloadService一直在後臺執行,繫結服務則可以讓MainActivity和DownloadService進行通訊,因此兩個方法呼叫都是必不可少的,在onCreate()方法的最後,我們還進行了WRITE_EXTERNAL_STORAGE的執行時許可權申請,因為下載檔案是要下載到SD卡的Download目錄下的,如果沒有這個許可權的話,我們整個程式都無法正常工作.

接下來的程式碼就非常簡單了,在onClick()方法中我們對點選事件進行判斷,如果點選了開始按鈕就呼叫DownloadBinder的startDownload()方法,如果點選了暫停按鈕就呼叫pauseDownload()方法,如果點選了取消按鈕就呼叫cancelDownload()方法,startDownload()方法中你可以傳入任意的下載地址.

另外還有一點需要注意,如果活動被銷燬了,那麼一定要記得對服務進行解綁,不然就有可能會造成記憶體洩漏,這裡我們在onDestroy()方法中完成了解綁的操作.

現在只差最後一步了,我們還需要在AndroidManifest.xml檔案中宣告使用到的許可權,如下:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.servicebestpractice">

    <uses-permission android:name="android.permission.INTERNET"/>
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
        <activity android:name=".MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
        <service android:name=".DownloadService"
            android:enabled="true"
            android:exported="true"/>
    </application>

</manifest>

其中,由於我們的程式使用到了網路和訪問SD卡的功能,因此需要宣告INTERNET和WRITE_EXTERNAL_STORAGE這兩個許可權.

這樣所有的程式碼都編寫完了.


相關文章