断点续传---多线程下载进阶(三)

断点续传---多线程下载进阶(三)_第1张图片

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"
    android:paddingBottom="@dimen/activity_vertical_margin"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin"
    tools:context=".MainActivity" >

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

</RelativeLayout>

item.xml

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout 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/tv_fileName"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="imooc.apk" />

    <ProgressBar
        android:id="@+id/pb_progress"
        style="?android:attr/progressBarStyleHorizontal"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" 
        android:layout_below="@id/tv_fileName"/>

    <Button
        android:id="@+id/btn_stop"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentRight="true"
        android:layout_below="@id/pb_progress"
        android:text="暂停" />

    <Button
        android:id="@+id/btn_start"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_below="@+id/pb_progress"
        android:layout_toLeftOf="@id/btn_stop"
        android:text="下载" />

</RelativeLayout>

FileInfo

package com.download.entities;

import java.io.Serializable;

public class FileInfo implements Serializable {
	private int id;
	private String url;
	private String fileName;
	private int length;
	private int finished;

	public FileInfo(int id, String url, String fileName, int length,
			int finished) {
		this.id = id;
		this.url = url;
		this.fileName = fileName;
		this.length = length;
		this.finished = finished;
	}

	public int getId() {
		return id;
	}

	public void setId(int id) {
		this.id = id;
	}

	public String getUrl() {
		return url;
	}

	public void setUrl(String url) {
		this.url = url;
	}

	public String getFileName() {
		return fileName;
	}

	public void setFileName(String fileName) {
		this.fileName = fileName;
	}

	public int getLength() {
		return length;
	}

	public void setLength(int length) {
		this.length = length;
	}

	public int getFinished() {
		return finished;
	}

	public void setFinished(int finished) {
		this.finished = finished;
	}

	@Override
	public String toString() {
		return "FileInfo [id=" + id + ", url=" + url + ", fileName=" + fileName
				+ ", length=" + length + ", finished=" + finished + "]";
	}

}

ThreadInfo

package com.download.entities;

import android.R.integer;

public class ThreadInfo {
	private int id;
	private String url;
	private int start;
	private int end;
	private int finished;

	public ThreadInfo() {
	}

	public ThreadInfo(int id, String url, int start, int end, int finished) {
		this.id = id;
		this.url = url;
		this.start = start;
		this.end = end;
		this.finished = finished;
	}

	public int getId() {
		return id;
	}

	public void setId(int id) {
		this.id = id;
	}

	public String getUrl() {
		return url;
	}

	public void setUrl(String url) {
		this.url = url;
	}

	public int getStart() {
		return start;
	}

	public void setStart(int start) {
		this.start = start;
	}

	public int getEnd() {
		return end;
	}

	public void setEnd(int end) {
		this.end = end;
	}

	public int getFinished() {
		return finished;
	}

	public void setFinished(int finished) {
		this.finished = finished;
	}

	@Override
	public String toString() {
		return "ThreadInfo [id=" + id + ", url=" + url + ", start=" + start
				+ ", end=" + end + ", finished=" + finished + "]";
	}

}

ThreadDAO

package com.download.db;

import java.util.List;

import com.download.entities.ThreadInfo;

public interface ThreadDAO {
	public void insertThread(ThreadInfo threadInfo);

	public void deleteThread(String url);

	public void updateThread(String url, int thread_id, int finished);

	public List<ThreadInfo> getThreads(String url);

	public boolean isExists(String url, int thread_id);
}


DBHelper

package com.download.db;

import android.R.integer;
import android.content.Context;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteDatabase.CursorFactory;
import android.database.sqlite.SQLiteOpenHelper;

/**
 * 数据库帮助类
 */
public class DBHelper extends SQLiteOpenHelper {
	private static final String DB_NAME = "download.db";
	private static final int VERSION = 1;
	private static final String SQL_CREATE = "create table thread_info(_id integer primary key autoincrement,"
			+ "thread_id integer, url text, start integer, end integer, finished integer)";
	private static final String SQL_DROP = "drop table if exists thread_info";
	private static DBHelper sDbHelper = null;

	private DBHelper(Context context) {
		super(context, DB_NAME, null, VERSION);
	}

	public static DBHelper getInstance(Context context) {
		if (null == sDbHelper) {
			sDbHelper = new DBHelper(context);
		}

		return sDbHelper;
	}

	@Override
	public void onCreate(SQLiteDatabase db) {
		db.execSQL(SQL_CREATE);
	}

	@Override
	public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
		db.execSQL(SQL_DROP);
		db.execSQL(SQL_CREATE);
	}

}


ThreadDAOImpl

package com.download.db;

import java.util.ArrayList;
import java.util.List;

import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;

import com.download.entities.ThreadInfo;

/**
 * 数据访问接口实现
 */
public class ThreadDAOImpl implements ThreadDAO {
	private DBHelper mHelper = null;

	public ThreadDAOImpl(Context context) {
		mHelper = DBHelper.getInstance(context);
	}

	@Override
	public synchronized void insertThread(ThreadInfo threadInfo) {
		SQLiteDatabase db = mHelper.getWritableDatabase();
		db.execSQL(
				"insert into thread_info(thread_id,url,start,end,finished) values(?,?,?,?,?)",
				new Object[] { threadInfo.getId(), threadInfo.getUrl(),
						threadInfo.getStart(), threadInfo.getEnd(),
						threadInfo.getFinished() });
		db.close();
	}

	@Override
	public synchronized void deleteThread(String url) {
		SQLiteDatabase db = mHelper.getWritableDatabase();
		db.execSQL("delete from thread_info where url = ?",
				new Object[] { url });
		db.close();
	}

	@Override
	public synchronized void updateThread(String url, int thread_id,
			int finished) {
		SQLiteDatabase db = mHelper.getWritableDatabase();
		db.execSQL(
				"update thread_info set finished = ? where url = ? and thread_id = ?",
				new Object[] { finished, url, thread_id });
		db.close();
	}

	@Override
	public List<ThreadInfo> getThreads(String url) {
		List<ThreadInfo> list = new ArrayList<ThreadInfo>();

		SQLiteDatabase db = mHelper.getReadableDatabase();
		Cursor cursor = db.rawQuery("select * from thread_info where url = ?",
				new String[] { url });
		while (cursor.moveToNext()) {
			ThreadInfo threadInfo = new ThreadInfo();
			threadInfo.setId(cursor.getInt(cursor.getColumnIndex("thread_id")));
			threadInfo.setUrl(cursor.getString(cursor.getColumnIndex("url")));
			threadInfo.setStart(cursor.getInt(cursor.getColumnIndex("start")));
			threadInfo.setEnd(cursor.getInt(cursor.getColumnIndex("end")));
			threadInfo.setFinished(cursor.getInt(cursor
					.getColumnIndex("finished")));
			list.add(threadInfo);
		}
		cursor.close();
		db.close();
		return list;
	}

	@Override
	public boolean isExists(String url, int thread_id) {
		SQLiteDatabase db = mHelper.getReadableDatabase();
		Cursor cursor = db.rawQuery(
				"select * from thread_info where url = ? and thread_id = ?",
				new String[] { url, thread_id + "" });
		boolean exists = cursor.moveToNext();
		cursor.close();
		db.close();
		return exists;
	}
}

MainActivity

package com.download.app;

import java.util.ArrayList;
import java.util.List;

import android.app.Activity;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.Bundle;
import android.util.Log;
import android.view.KeyEvent;
import android.widget.ListView;
import android.widget.Toast;

import com.download.entities.FileInfo;
import com.download.services.DownloadService;
import com.imooc.DownLoad.R;

public class MainActivity extends Activity {

	public static MainActivity mMainActivity = null;
	private ListView mListView = null;
	private List<FileInfo> mFileInfoList = null;
	private FileListAdapter mAdapter = null;

	@Override
	protected void onCreate(Bundle savedInstanceState) {
		super.onCreate(savedInstanceState);
		setContentView(R.layout.activity_main);
		// listView视图控件
		mListView = (ListView) findViewById(R.id.lv_downLoad);
		// data数据源
		mFileInfoList = new ArrayList<FileInfo>();

		// 初始化文件信息对象
		FileInfo fileInfo = null;
		// 为方便测试,用Tomcat作服务器
		for (int i = 0; i < 13; i++) {
			fileInfo = new FileInfo(i, "http://192.168.1.100:8080/imooc" + i
					+ ".apk", "imooc" + i + ".apk", 0, 0);
			mFileInfoList.add(fileInfo);
		}
		// 适配器
		mAdapter = new FileListAdapter(this, mFileInfoList);
		// 设置适配器
		mListView.setAdapter(mAdapter);

		// 注册广播接收器
		IntentFilter filter = new IntentFilter();
		filter.addAction(DownloadService.ACTION_UPDATE);
		filter.addAction(DownloadService.ACTION_FINISHED);
		registerReceiver(mReceiver, filter);

		mMainActivity = this;
	}

	protected void onDestroy() {
		super.onDestroy();
		unregisterReceiver(mReceiver);
	}

	/**
	 * 更新UI的广播接收器
	 */
	BroadcastReceiver mReceiver = new BroadcastReceiver() {
		@Override
		public void onReceive(Context context, Intent intent) {
			if (DownloadService.ACTION_UPDATE.equals(intent.getAction())) {
				int finised = intent.getIntExtra("finished", 0);
				int id = intent.getIntExtra("id", 0);
				mAdapter.updateProgress(id, finised);
				Log.i("mReceiver", id + "-finised = " + finised);
			} else if (DownloadService.ACTION_FINISHED.equals(intent
					.getAction())) {
				// 下载结束
				FileInfo fileInfo = (FileInfo) intent
						.getSerializableExtra("fileInfo");
				mAdapter.updateProgress(fileInfo.getId(), 0);
				Toast.makeText(
						MainActivity.this,
						mFileInfoList.get(fileInfo.getId()).getFileName()
								+ "下载完毕", 0).show();
			}
		}
	};

}

FileListAdapter

package com.download.app;

import java.util.List;

import android.content.Context;
import android.content.Intent;
import android.view.LayoutInflater;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
import android.widget.Button;
import android.widget.ProgressBar;
import android.widget.TextView;

import com.download.entities.FileInfo;
import com.download.services.DownloadService;
import com.imooc.DownLoad.R;

public class FileListAdapter extends BaseAdapter {
	private Context mContext;
	private List<FileInfo> mList;

	public FileListAdapter(Context context, List<FileInfo> fileInfos) {
		this.mContext = context;
		this.mList = fileInfos;
	}

	@Override
	public int getCount() {
		return mList.size();
	}

	@Override
	public View getView(int position, View convertView, ViewGroup parent) {
		ViewHolder viewHolder = null;
		final FileInfo fileInfo = mList.get(position);

		if (convertView != null) {
			viewHolder = (ViewHolder) convertView.getTag();

			if (!viewHolder.mFileName.getTag().equals(
					Integer.valueOf(fileInfo.getId()))) {
				convertView = null;
			}
		}

		if (null == convertView) {
			LayoutInflater inflater = LayoutInflater.from(mContext);
			convertView = inflater.inflate(R.layout.item, null);

			viewHolder = new ViewHolder(
					(TextView) convertView.findViewById(R.id.tv_fileName),
					(ProgressBar) convertView.findViewById(R.id.pb_progress),
					(Button) convertView.findViewById(R.id.btn_start),
					(Button) convertView.findViewById(R.id.btn_stop));
			convertView.setTag(viewHolder);

			viewHolder.mFileName.setText(fileInfo.getFileName());
			viewHolder.mProgressBar.setMax(100);
			viewHolder.mStartBtn.setOnClickListener(new OnClickListener() {
				@Override
				public void onClick(View v) {
					// 通知Service开始下载
					Intent intent = new Intent(mContext, DownloadService.class);
					intent.setAction(DownloadService.ACTION_START);
					intent.putExtra("fileInfo", fileInfo);
					mContext.startService(intent);
				}
			});
			viewHolder.mStopBtn.setOnClickListener(new OnClickListener() {
				@Override
				public void onClick(View v) {
					Intent intent = new Intent(mContext, DownloadService.class);
					intent.setAction(DownloadService.ACTION_STOP);
					intent.putExtra("fileInfo", fileInfo);
					mContext.startService(intent);
				}
			});

			// 将viewHolder.mFileName的Tag设为fileInfo的ID,用于唯一标识viewHolder.mFileName
			viewHolder.mFileName.setTag(Integer.valueOf(fileInfo.getId()));
		}

		viewHolder.mProgressBar.setProgress(fileInfo.getFinished());

		return convertView;
	}

	public void updateProgress(int id, int progress) {
		FileInfo fileInfo = mList.get(id);
		fileInfo.setFinished(progress);
		notifyDataSetChanged();
	}

	private static class ViewHolder {
		TextView mFileName;
		ProgressBar mProgressBar;
		Button mStartBtn;
		Button mStopBtn;

		public ViewHolder(TextView mFileName, ProgressBar mProgressBar,
				Button mStartBtn, Button mStopBtn) {
			this.mFileName = mFileName;
			this.mProgressBar = mProgressBar;
			this.mStartBtn = mStartBtn;
			this.mStopBtn = mStopBtn;
		}
	}

	@Override
	public Object getItem(int position) {
		return null;
	}

	@Override
	public long getItemId(int position) {
		return 0;
	}
}

DownloadService

package com.download.services;

import java.io.File;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.LinkedHashMap;
import java.util.Map;

import org.apache.http.HttpStatus;

import com.download.entities.FileInfo;

import android.app.Service;
import android.content.Intent;
import android.os.Environment;
import android.os.Handler;
import android.os.IBinder;
import android.util.Log;
import android.webkit.WebView.FindListener;

public class DownloadService extends Service {
	public static final String DOWNLOAD_PATH = Environment
			.getExternalStorageDirectory().getAbsolutePath() + "/downloads/";
	public static final String ACTION_START = "ACTION_START";
	public static final String ACTION_STOP = "ACTION_STOP";
	public static final String ACTION_UPDATE = "ACTION_UPDATE";
	public static final String ACTION_FINISHED = "ACTION_FINISHED";
	public static final int MSG_INIT = 0;
	private String TAG = "DownloadService";
	private Map<Integer, DownloadTask> mTasks = new LinkedHashMap<Integer, DownloadTask>();

	@Override
	public int onStartCommand(Intent intent, int flags, int startId) {
		// 获得Activity传过来的参数
		if (ACTION_START.equals(intent.getAction())) {
			FileInfo fileInfo = (FileInfo) intent
					.getSerializableExtra("fileInfo");
			Log.i(TAG, "Start:" + fileInfo.toString());
			// 启动初始化线程
			new InitThread(fileInfo).start();
		} else if (ACTION_STOP.equals(intent.getAction())) {
			FileInfo fileInfo = (FileInfo) intent
					.getSerializableExtra("fileInfo");
			Log.i(TAG, "Stop:" + fileInfo.toString());

			// 从集合中取出下载任务
			DownloadTask task = mTasks.get(fileInfo.getId());
			if (task != null) {
				task.isPause = true;
			}
		}

		return super.onStartCommand(intent, flags, startId);
	}

	private Handler mHandler = new Handler() {
		public void handleMessage(android.os.Message msg) {
			switch (msg.what) {
			case MSG_INIT:
				FileInfo fileInfo = (FileInfo) msg.obj;
				Log.i(TAG, "Init:" + fileInfo);
				// 启动下载任务
				DownloadTask task = new DownloadTask(DownloadService.this,
						fileInfo, 3);
				task.downLoad();
				// 把下载任务添加到集合中
				mTasks.put(fileInfo.getId(), task);
				break;

			default:
				break;
			}
		};
	};

	private class InitThread extends Thread {
		private FileInfo mFileInfo = null;

		public InitThread(FileInfo mFileInfo) {
			this.mFileInfo = mFileInfo;
		}

		/**
		 * @see java.lang.Thread#run()
		 */
		@Override
		public void run() {
			HttpURLConnection connection = null;
			RandomAccessFile raf = null;

			try {
				// 连接网络文件
				URL url = new URL(mFileInfo.getUrl());
				connection = (HttpURLConnection) url.openConnection();
				connection.setConnectTimeout(5000);
				connection.setRequestMethod("GET");
				int length = -1;

				if (connection.getResponseCode() == HttpStatus.SC_OK) {
					// 获得文件的长度
					length = connection.getContentLength();
				}

				if (length <= 0) {
					return;
				}

				File dir = new File(DOWNLOAD_PATH);
				if (!dir.exists()) {
					dir.mkdir();
				}

				// 在本地创建文件
				File file = new File(dir, mFileInfo.getFileName());
				raf = new RandomAccessFile(file, "rwd");
				// 设置文件长度
				raf.setLength(length);
				mFileInfo.setLength(length);
				mHandler.obtainMessage(MSG_INIT, mFileInfo).sendToTarget();
			} catch (Exception e) {
				e.printStackTrace();
			} finally {
				if (connection != null) {
					connection.disconnect();
				}
				if (raf != null) {
					try {
						raf.close();
					} catch (IOException e) {
						e.printStackTrace();
					}
				}
			}
		}
	}

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

}

DownloadTask

package com.download.services;

import java.io.File;
import java.io.InputStream;
import java.io.RandomAccessFile;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLConnection;
import java.util.ArrayList;
import java.util.List;

import org.apache.http.HttpStatus;

import android.R.integer;
import android.content.Context;
import android.content.Intent;
import android.nfc.Tag;
import android.util.Log;
import android.widget.Toast;

import com.download.app.MainActivity;
import com.download.db.ThreadDAO;
import com.download.db.ThreadDAOImpl;
import com.download.entities.FileInfo;
import com.download.entities.ThreadInfo;

/**
 * 下载任务类
 */
public class DownloadTask {
	private Context mContext = null;
	private FileInfo mFileInfo = null;
	private ThreadDAO mDao = null;
	private int mFinised = 0;
	public boolean isPause = false;
	private int mThreadCount = 1; // 线程数量
	private List<DownloadThread> mDownloadThreadList = null; // 线程集合

	public DownloadTask(Context mContext, FileInfo mFileInfo, int count) {
		this.mContext = mContext;
		this.mFileInfo = mFileInfo;
		this.mThreadCount = count;
		mDao = new ThreadDAOImpl(mContext);
	}

	public void downLoad() {
		// 读取数据库的线程信息
		List<ThreadInfo> threads = mDao.getThreads(mFileInfo.getUrl());
		ThreadInfo threadInfo = null;

		if (0 == threads.size()) {
			// 计算每个线程下载长度
			int len = mFileInfo.getLength() / mThreadCount;
			for (int i = 0; i < mThreadCount; i++) {
				// 初始化线程信息对象
				threadInfo = new ThreadInfo(i, mFileInfo.getUrl(), len * i,
						(i + 1) * len - 1, 0);

				if (mThreadCount - 1 == i) // 处理最后一个线程下载长度不能整除的问题
				{
					threadInfo.setEnd(mFileInfo.getLength());
				}

				// 添加到线程集合中
				threads.add(threadInfo);
				mDao.insertThread(threadInfo);
			}
		}

		mDownloadThreadList = new ArrayList<DownloadTask.DownloadThread>();
		// 启动多个线程进行下载
		for (ThreadInfo info : threads) {
			DownloadThread thread = new DownloadThread(info);
			thread.start();
			// 添加到线程集合中
			mDownloadThreadList.add(thread);
		}
	}

	/**
	 * 下载线程
	 */
	private class DownloadThread extends Thread {
		private ThreadInfo mThreadInfo = null;
		public boolean isFinished = false; // 线程是否执行完毕

		public DownloadThread(ThreadInfo mInfo) {
			this.mThreadInfo = mInfo;
		}

		@Override
		public void run() {
			HttpURLConnection connection = null;
			RandomAccessFile raf = null;
			InputStream inputStream = null;

			try {
				URL url = new URL(mThreadInfo.getUrl());
				connection = (HttpURLConnection) url.openConnection();
				connection.setConnectTimeout(5000);
				connection.setRequestMethod("GET");
				// 设置下载位置
				int start = mThreadInfo.getStart() + mThreadInfo.getFinished();
				connection.setRequestProperty("Range", "bytes=" + start + "-"
						+ mThreadInfo.getEnd());
				// 设置文件写入位置
				File file = new File(DownloadService.DOWNLOAD_PATH,
						mFileInfo.getFileName());
				raf = new RandomAccessFile(file, "rwd");
				raf.seek(start);
				Intent intent = new Intent();
				intent.setAction(DownloadService.ACTION_UPDATE);
				mFinised += mThreadInfo.getFinished();
				Log.i("mFinised", mThreadInfo.getId() + "finished = "
						+ mThreadInfo.getFinished());
				// 开始下载
				if (connection.getResponseCode() == HttpStatus.SC_PARTIAL_CONTENT) {
					// 读取数据
					inputStream = connection.getInputStream();
					byte buf[] = new byte[1024 << 2];
					int len = -1;
					long time = System.currentTimeMillis();
					while ((len = inputStream.read(buf)) != -1) {
						// 写入文件
						raf.write(buf, 0, len);
						// 累加整个文件完成进度
						mFinised += len;
						// 累加每个线程完成的进度
						mThreadInfo
								.setFinished(mThreadInfo.getFinished() + len);
						if (System.currentTimeMillis() - time > 1000) {
							time = System.currentTimeMillis();
							int f = mFinised * 100 / mFileInfo.getLength();
							if (f > mFileInfo.getFinished()) {
								intent.putExtra("finished", f);
								intent.putExtra("id", mFileInfo.getId());
								mContext.sendBroadcast(intent);
							}
						}

						// 在下载暂停时,保存下载进度
						if (isPause) {
							mDao.updateThread(mThreadInfo.getUrl(),
									mThreadInfo.getId(),
									mThreadInfo.getFinished());

							Log.i("mThreadInfo", mThreadInfo.getId()
									+ "finished = " + mThreadInfo.getFinished());

							return;
						}
					}

					// 标识线程执行完毕
					isFinished = true;
					checkAllThreadFinished();
				}
			} catch (Exception e) {
				e.printStackTrace();
			} finally {
				try {
					if (connection != null) {
						connection.disconnect();
					}
					if (raf != null) {
						raf.close();
					}
					if (inputStream != null) {
						inputStream.close();
					}
				} catch (Exception e2) {
					e2.printStackTrace();
				}
			}
		}
	}

	/**
	 * 判断所有的线程是否执行完毕
	 */
	private synchronized void checkAllThreadFinished() {
		boolean allFinished = true;

		// 遍历线程集合,判断线程是否都执行完毕
		for (DownloadThread thread : mDownloadThreadList) {
			if (!thread.isFinished) {
				allFinished = false;
				break;
			}
		}

		if (allFinished) {
			// 删除下载记录
			mDao.deleteThread(mFileInfo.getUrl());
			// 发送广播知道UI下载任务结束
			Intent intent = new Intent(DownloadService.ACTION_FINISHED);
			intent.putExtra("fileInfo", mFileInfo);
			mContext.sendBroadcast(intent);
		}
	}
}



你可能感兴趣的:(断点续传---多线程下载进阶(三))