Android 剪貼板詳解
Android 提供了一個強大的剪貼板框架,用于復制和粘貼。 它支持文本、二進制數據流或其它復雜的數據。
Android 剪貼板框架如圖
?
從圖中可以看出,Android 剪貼板框架主要涉及到 ClipboardManager 、 ClipData 、 ClipData.Item 、 ClipDescription 這四個類。
關于這四個類的簡介如下:
- ClipboardManager 是系統全局的剪貼板對象,通過 context.getSystemService(CLIPBOARD_SERVICE) 獲取。
- ClipData ,即 clip 對象,在系統剪貼板里只存在一個,當另一個 clip 對象進來時,前一個 clip 對象會消失。
- ClipData.Item ,即 data item,它包含了文本、 Uri 或者 Intent 數據,一個 clip 對象可以包含一個或多個 Item 對象。通過 addItem(ClipData.Item item) 可以實現往 clip 對象中添加 Item。
- 文本:文本是直接放在 clip 對象中,然后放在剪貼板里;粘貼這個字符串的時候直接從剪貼板拿到這個對象,把字符串放入你的應用存儲中。
- Uri: 對于復雜數據的剪貼拷貝并不是直接將數據放入內存,而是通過 Uri 來實現,畢竟 Uri 的中文名叫:統一資源標識符。通過 Uri 能定位手機上所有資源,這當然能實現拷貝了,只不過需要做一些額外的處理工作。(對于 Uri 不是很理解,如有誤,望指正~)
- Intent:復制的時候 Intent 會被直接放入 clip 對象,這相當于拷貝了一個快捷方式。
-
ClipDescription ,即 clip metadata,它包含了 ClipData 對象的 metadata 信息。可以通過 getMimeType(int index) 獲取(一般 index = 0,有興趣的可以去看下 ClipData 的源碼)。MimeType 一般有以下四種類型:
// 對應 ClipData newPlainText(label, text) 的 MimeType public static final String MIMETYPE_TEXT_PLAIN = "text/plain"; // 對應 ClipData.newHtmlText(label, text, htmlText) 的 MimeType public static final String MIMETYPE_TEXT_HTML = "text/html"; // 對應 ClipData.newUri(cr, label, uri) 的 MimeType public static final String MIMETYPE_TEXT_URILIST = "text/uri-list"; // 對應 ClipData.newIntent(label, intent) 的 MimeType public static final String MIMETYPE_TEXT_INTENT = "text/vnd.android.intent";
但 MIMETYPE_TEXT_URILIST 有點特殊,當 Uri 為 content://uri 時,它會轉為具體的 MimeType ,后面會有例子講到。
剪貼板簡單使用
以拷貝文本為例,剪貼板的使用可以分為以下幾步:
-
獲取 ClipManager 對象
ClipManager clipManager = (ClipManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
-
將數據放到 clip 對象中
ClipData clip = ClipData.newPlainText("simple text copy", "Hello World!");
類似的方法還有
//創建一個包含 htmlText 的 ClipData //一般在瀏覽器中對網頁進行拷貝的時候會調用此方法 //其中 htmlText 是包含 HTML 標簽的字符串 static public ClipData newHtmlText(CharSequence label, CharSequence text, String htmlText); //創建一個包含 Intent 的 ClipData static public ClipData newIntent(CharSequence label, Intent intent); //創建一個包含 Uri 的 ClipData,MimeType 會根據 Uri 進行修改 static public ClipData newUri(ContentResolver resolver, CharSequence label, Uri uri); //與 newUri 相對應,但是并不會根據 Uri 修改 MimeType static public ClipData newRawUri(CharSequence label, Uri uri);
-
將 clip 對象放入剪貼板
clipManager.setPrimaryClip(clip);
-
從剪貼板中獲取 clip 對象
//判斷剪貼板里是否有內容 if(!clipManager.hasPrimaryClip()) { return; } ClipData clip = clipManager.getPrimaryClip(); //獲取 ClipDescription ClipDescription description = clip.getPrimaryClipDescription(); //獲取 label String label = description.getLabel().toString(); //獲取 text String text = clip.getItemAt(0).getText().toString();
實踐出真知
講道理,實踐出真知,咱們程序員的實踐就是代碼,下面上代碼。等等,先上 Demo 的運行效果圖。第一次做 Gif ,好緊張,哈哈~若動態圖不動,查看原圖連接應該就可以了~
?
對于剪貼板大部分操作都封裝在 ClipboardUtil.java 里,使用時請記錄調用 init(Context context) 方法進行初始化,建議在 Application.onCreate() 中進行,否則會造成內存泄漏。
AndroidManifest.xml :
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.littlejie.clipboard">
<!-- content://contacts/people 需要使用該權限-->
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
<application
android:name=".ClipboardApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
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>
</application>
</manifest>
ClipboardApplication.java :
public class ClipboardApplication extends Application {
@Override
public void onCreate() {
super.onCreate();
ClipboardUtil.init(this);
}
}
build.gradle :
apply plugin: 'com.android.application'
android {
compileSdkVersion 24
buildToolsVersion "25.0.0"
defaultConfig {
applicationId "com.littlejie.clipboard"
minSdkVersion 16
//由于Android6.0之后有運行時權限,故修改版本號使其不用運行時獲取讀取聯系人權限
//關于 compileSdkVersion 、 minSdkVersion 、 targetSdkVersion 三者之間的區別
//有興趣的可以自行谷歌、百度
targetSdkVersion 21
versionCode 1
versionName "1.0"
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
}
// Something else
}
// Something else
MainActivity.java :
public class MainActivity extends Activity implements View.OnClickListener,
ClipboardUtil.OnPrimaryClipChangedListener {
private static final String TAG = MainActivity.class.getSimpleName();
private static final String MIME_CONTACT = "vnd.android.cursor.dir/person";
private TextView mTvCopied;
private ClipboardUtil mClipboard;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
//ClipboardUtil在Application的onCreate中調用init初始化
mClipboard = ClipboardUtil.getInstance();
mClipboard.addOnPrimaryClipChangedListener(this);
mTvCopied = (TextView) findViewById(R.id.tv_copied);
findViewById(R.id.btn_copy_text).setOnClickListener(this);
findViewById(R.id.btn_copy_rich_text).setOnClickListener(this);
findViewById(R.id.btn_copy_intent).setOnClickListener(this);
findViewById(R.id.btn_copy_uri).setOnClickListener(this);
findViewById(R.id.btn_copy_multiple).setOnClickListener(this);
}
@Override
protected void onDestroy() {
super.onDestroy();
mClipboard.removeOnPrimaryClipChangedListener(this);
}
@Override
public void onPrimaryClipChanged(ClipboardManager clipboardManager) {
Log.d(TAG, clipboardManager.getPrimaryClip().toString());
mTvCopied.setText(clipboardManager.getPrimaryClip().toString());
ClipData data = clipboardManager.getPrimaryClip();
String mimeType = mClipboard.getPrimaryClipMimeType();
Log.d(TAG, "mimeType = " + mimeType);
//一般來說,收到系統 onPrimaryClipChanged() 回調時,剪貼板一定不為空
//但為了保險起見,在這邊還是做了空指針判斷
if (data == null) {
return;
}
//前四種為剪貼板默認的MimeType,但是當拷貝數據為Uri時,會出現其它MimeType,需要特殊處理
if (ClipDescription.MIMETYPE_TEXT_INTENT.equals(mimeType)) {
//一個 ClipData 可以有多個 ClipData.Item,這里假設只有一個
startActivity(data.getItemAt(0).getIntent());
} else if (ClipDescription.MIMETYPE_TEXT_HTML.equals(mimeType)) {
} else if (ClipDescription.MIMETYPE_TEXT_PLAIN.equals(mimeType)) {
} else if (ClipDescription.MIMETYPE_TEXT_URILIST.equals(mimeType)) {
//當uri=content://media/external時,copyUri會進入此if-else語句
} else if (MIME_CONTACT.equals(mimeType)) {
Log.d(TAG, mClipboard.coercePrimaryClipToText().toString());
//當uri=content://contacts/people時,copyUri會進入此if-else語句
StringBuilder sb = new StringBuilder(mTvCopied.getText() + "\n\n");
int index = 1;
Uri uri = data.getItemAt(0).getUri();
Cursor cursor = getContentResolver().query(uri, null, null, null, null);
while (cursor.moveToNext()) {
//打印所有聯系人姓名
String name = cursor.getString(cursor.getColumnIndex(ContactsContract.Contacts.DISPLAY_NAME));
sb.append("聯系人 " + index++ + " : " + name + "\n");
}
mTvCopied.setText(sb.toString());
}
}
@Override
public void onClick(View v) {
switch (v.getId()) {
case R.id.btn_copy_text:
//普通的文本拷貝
mClipboard.copyText("文本拷貝", "我是文本~");
break;
case R.id.btn_copy_rich_text:
//平時在瀏覽器網頁上執行的copy就是這種,一般瀏覽器會使用 ClipData.newHtmlText(label, text, htmlText)往剪貼板里塞東西
//所以,將這段內容拷貝到諸如 Google+ 、 Gmail 等能處理富文本內容的應用能看到保留格式的內容
//補充:測試了 QQ瀏覽器 、 UC瀏覽器,發現他們拷貝的內容只是單純的文本,即使用 ClipData.newPlainText(label, text) 往剪貼板里塞東西
mClipboard.copyHtmlText("HTML拷貝", "我是HTML",
"<strong style=\"margin: 0px; padding: 0px; border: 0px; color: rgb(64, 64, 64); font-family: STHeiti, 'Microsoft YaHei', Helvetica, Arial, sans-serif; font-size: 17px; font-style: normal; font-variant: normal; letter-spacing: normal; line-height: 25.920001983642578px; orphans: auto; text-align: justify; text-indent: 34.560001373291016px; text-transform: none; white-space: normal; widows: auto; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(235, 23, 23);\">央視</strong>");
break;
case R.id.btn_copy_intent:
mClipboard.copyIntent("Intent拷貝", getOpenBrowserIntent());
break;
case R.id.btn_copy_uri:
mClipboard.copyUri(getContentResolver(), "Uri拷貝", Uri.parse("content://contacts/people"));
//mClipboard.copyUri(getContentResolver(), "Uri拷貝", Uri.parse("content://media/external"));
break;
case R.id.btn_copy_multiple:
copyMultiple();
break;
}
}
/**
* 打開瀏覽器的Intent
*
* @return
*/
private Intent getOpenBrowserIntent() {
Uri uri = Uri.parse("http://www.baidu.com");
Intent intent = new Intent(Intent.ACTION_VIEW, uri);
return intent;
}
/**
* 拷貝多組數據到剪貼板
*/
private void copyMultiple() {
//ClipData 目前僅能設置單個 MimeType
List<ClipData.Item> items = new ArrayList<>();
//故 ClipData.Item 的類型必須和 MimeType 設置的相符
//比如都是文字,都是URI或都是Intent,而不是混合各種形式。
ClipData.Item item1 = new ClipData.Item("text1");
ClipData.Item item2 = new ClipData.Item("text2");
ClipData.Item item3 = new ClipData.Item("text3");
ClipData.Item item4 = new ClipData.Item("text4");
items.add(item1);
items.add(item2);
items.add(item3);
items.add(item4);
mClipboard.copyMultiple("Multiple Copy", ClipDescription.MIMETYPE_TEXT_PLAIN, items);
}
}
ClipboardUtil.java :
/**
* 剪貼板工具類
* http://developer.android.com/guide/topics/text/copy-paste.html
* Created by littlejie on 2016/4/15.
*/
public class ClipboardUtil {
public static final String TAG = ClipboardUtil.class.getSimpleName();
private static final String MIME_CONTACT = "vnd.android.cursor.dir/person";
/**
* 由于系統剪貼板在某些情況下會多次調用,但調用間隔基本不會超過5ms
* 考慮到用戶操作,將閾值設為100ms,過濾掉前幾次無效回調
*/
private static final int THRESHOLD = 100;
private Context mContext;
private static ClipboardUtil mInstance;
private ClipboardManager mClipboardManager;
private Handler mHandler = new Handler();
private ClipboardManager.OnPrimaryClipChangedListener onPrimaryClipChangedListener = new ClipboardManager.OnPrimaryClipChangedListener() {
@Override
public void onPrimaryClipChanged() {
mHandler.removeCallbacks(mRunnable);
mHandler.postDelayed(mRunnable, THRESHOLD);
}
};
private ClipboardChangedRunnable mRunnable = new ClipboardChangedRunnable();
private List<OnPrimaryClipChangedListener> mOnPrimaryClipChangedListeners = new ArrayList<>();
/**
* 自定義 OnPrimaryClipChangedListener
* 用于處理某些情況下系統多次調用 onPrimaryClipChanged()
*/
public interface OnPrimaryClipChangedListener {
void onPrimaryClipChanged(ClipboardManager clipboardManager);
}
private class ClipboardChangedRunnable implements Runnable {
@Override
public void run() {
for (OnPrimaryClipChangedListener listener : mOnPrimaryClipChangedListeners) {
listener.onPrimaryClipChanged(mClipboardManager);
}
}
}
private ClipboardUtil(Context context) {
mContext = context;
mClipboardManager = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
mClipboardManager.addPrimaryClipChangedListener(onPrimaryClipChangedListener);
}
/**
* 單例。暫時不清楚此處傳 Activity 的 Context 是否會造成內存泄漏
* 建議在 Application 的 onCreate 方法中實現
*
* @param context
* @return
*/
public static ClipboardUtil init(Context context) {
if (mInstance == null) {
mInstance = new ClipboardUtil(context);
}
return mInstance;
}
/**
* 獲取ClipboardUtil實例,記得初始化
*
* @return
*/
public static ClipboardUtil getInstance() {
return mInstance;
}
public void addOnPrimaryClipChangedListener(OnPrimaryClipChangedListener listener) {
if (!mOnPrimaryClipChangedListeners.contains(listener)) {
mOnPrimaryClipChangedListeners.add(listener);
}
}
public void removeOnPrimaryClipChangedListener(OnPrimaryClipChangedListener listener) {
mOnPrimaryClipChangedListeners.remove(listener);
}
/**
* 判斷剪貼板內是否有數據
*
* @return
*/
public boolean hasPrimaryClip() {
return mClipboardManager.hasPrimaryClip();
}
/**
* 獲取剪貼板中第一條String
*
* @return
*/
public String getClipText() {
if (!hasPrimaryClip()) {
return null;
}
ClipData data = mClipboardManager.getPrimaryClip();
if (data != null
&& mClipboardManager.getPrimaryClipDescription().hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN)) {
return data.getItemAt(0).getText().toString();
}
return null;
}
/**
* 獲取剪貼板中第一條String
*
* @param context
* @return
*/
public String getClipText(Context context) {
return getClipText(context, 0);
}
/**
* 獲取剪貼板中指定位置item的string
*
* @param context
* @param index
* @return
*/
public String getClipText(Context context, int index) {
if (!hasPrimaryClip()) {
return null;
}
ClipData data = mClipboardManager.getPrimaryClip();
if (data == null) {
return null;
}
if (data.getItemCount() > index) {
return data.getItemAt(index).coerceToText(context).toString();
}
return null;
}
/**
* 將文本拷貝至剪貼板
*
* @param text
*/
public void copyText(String label, String text) {
ClipData clip = ClipData.newPlainText(label, text);
mClipboardManager.setPrimaryClip(clip);
}
/**
* 將HTML等富文本拷貝至剪貼板
*
* @param label
* @param text
* @param htmlText
*/
public void copyHtmlText(String label, String text, String htmlText) {
ClipData clip = ClipData.newHtmlText(label, text, htmlText);
mClipboardManager.setPrimaryClip(clip);
}
/**
* 將Intent拷貝至剪貼板
*
* @param label
* @param intent
*/
public void copyIntent(String label, Intent intent) {
ClipData clip = ClipData.newIntent(label, intent);
mClipboardManager.setPrimaryClip(clip);
}
/**
* 將Uri拷貝至剪貼板
* If the URI is a content: URI,
* this will query the content provider for the MIME type of its data and
* use that as the MIME type. Otherwise, it will use the MIME type
* {@link ClipDescription#MIMETYPE_TEXT_URILIST}.
* 如 uri = "content://contacts/people",那么返回的MIME type將變成"vnd.android.cursor.dir/person"
*
* @param cr ContentResolver used to get information about the URI.
* @param label User-visible label for the clip data.
* @param uri The URI in the clip.
*/
public void copyUri(ContentResolver cr, String label, Uri uri) {
ClipData clip = ClipData.newUri(cr, label, uri);
mClipboardManager.setPrimaryClip(clip);
}
/**
* 將多組數據放入剪貼板中,如選中ListView多個Item,并將Item的數據一起放入剪貼板
*
* @param label User-visible label for the clip data.
* @param mimeType mimeType is one of them:{@link android.content.ClipDescription#MIMETYPE_TEXT_PLAIN},
* {@link android.content.ClipDescription#MIMETYPE_TEXT_HTML},
* {@link android.content.ClipDescription#MIMETYPE_TEXT_URILIST},
* {@link android.content.ClipDescription#MIMETYPE_TEXT_INTENT}.
* @param items 放入剪貼板中的數據
*/
public void copyMultiple(String label, String mimeType, List<ClipData.Item> items) {
if (items == null) {
throw new NullPointerException("items is null");
}
int size = items.size();
ClipData clip = new ClipData(label, new String[]{mimeType}, items.get(0));
for (int i = 1; i < size; i++) {
clip.addItem(items.get(i));
}
mClipboardManager.setPrimaryClip(clip);
}
public CharSequence coercePrimaryClipToText() {
if (!hasPrimaryClip()) {
return null;
}
return mClipboardManager.getPrimaryClip().getItemAt(0).coerceToText(mContext);
}
public CharSequence coercePrimaryClipToStyledText() {
if (!hasPrimaryClip()) {
return null;
}
return mClipboardManager.getPrimaryClip().getItemAt(0).coerceToStyledText(mContext);
}
public CharSequence coercePrimaryClipToHtmlText() {
if (!hasPrimaryClip()) {
return null;
}
return mClipboardManager.getPrimaryClip().getItemAt(0).coerceToHtmlText(mContext);
}
/**
* 獲取當前剪貼板內容的MimeType
*
* @return 當前剪貼板內容的MimeType
*/
public String getPrimaryClipMimeType() {
if (!hasPrimaryClip()) {
return null;
}
return mClipboardManager.getPrimaryClipDescription().getMimeType(0);
}
/**
* 獲取剪貼板內容的MimeType
*
* @param clip 剪貼板內容
* @return 剪貼板內容的MimeType
*/
public String getClipMimeType(ClipData clip) {
return clip.getDescription().getMimeType(0);
}
/**
* 獲取剪貼板內容的MimeType
*
* @param clipDescription 剪貼板內容描述
* @return 剪貼板內容的MimeType
*/
public String getClipMimeType(ClipDescription clipDescription) {
return clipDescription.getMimeType(0);
}
/**
* 清空剪貼板
*/
public void clearClip() {
mClipboardManager.setPrimaryClip(null);
}
}
補充
以下補充幾點,是自己在測試剪貼板的過程中碰到,一是 OnPrimaryClipChangedListener 的多次回調,二是將剪貼板中的內容轉換為字符串。
關于 OnPrimaryClipChangedListener 的多次回調
細心的同學可能已經發現,上述代碼中,樓主并沒有直接使用 Android 的 OnPrimaryClipChangedListener ,而是自己對此進行了再次封裝。這是有原因的,在最初測試剪貼板的過程中,樓主發現一次拷貝過程可能會導致多次回調 onPrimaryClipChanged() 方法,日志如下:
# 第一次
com.littlejie.clipboard D/MainActivity: text/plain
# 第二次
com.littlejie.clipboard D/MainActivity: text/plain
com.littlejie.clipboard D/MainActivity: 央視
# 第三次
com.littlejie.clipboard D/MainActivity: text/html
com.littlejie.clipboard D/MainActivity: <strong style="margin: 0px; padding: 0px; border: 0px; color: rgb(64, 64, 64); font-family: STHeiti, 'Microsoft YaHei', Helvetica, Arial, sans-serif; font-size: 17px; font-style: normal; font-variant: normal; letter-spacing: normal; line-height: 25.920001983642578px; orphans: auto; text-align: justify; text-indent: 34.560001373291016px; text-transform: none; white-space: normal; widows: auto; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(246, 246, 246);">央視</strong>
com.littlejie.clipboard D/MainActivity: 央視
這肯定不是我們想要的結果,那么該怎么解決這個問題呢?
多次測試發現,發生多次回調的情況下,正確的拷貝結果都是最后一次回調獲取到的數據。
再打印一下 onPrimaryClipChanged() 回調時間吧,發現三次的間隔不超過 9ms ,而普通用戶一般不可能在如此短時間內完成多次拷貝。故我們可以定義一個變量存儲 onPrimaryClipChanged 的回調時間,當下次回調時相對前一次的時間間隔小于 100ms(合理假設),那么判定前一次回調事件無效。
# 第一次
onPrimaryClipChanged,time = 1481792153614
# 第二次
onPrimaryClipChanged,time = 1481792153620
# 第三次
onPrimaryClipChanged,time = 1481792153623
故才有了上訴的代碼。
將剪貼板中的數據強轉為字符串
一般來說,平時我們拷貝的都是文字,但是從上述內容可知,Android 剪貼板支持的不僅僅是文字,那對于 Uri 、 Intent 數據 Android 是如何把它們轉換成字符串的呢?有興趣的同學可以去查看 ClipData 下述三個方法的源碼。這里限于篇幅就不詳述了。
public CharSequence coerceToText(Context context);
public CharSequence coerceToStyledText(Context context);
public String coerceToHtmlText(Context context);
畫外:如何高效的復制粘貼
此部分內容原文見 Android 官方文檔 Copy and paste 最后一節,翻譯摘自Android中的復制粘貼 。
為了設計有效的復制粘貼功能,以下幾點需要注意:
- 任何時間,都只有一個clip對象在剪貼板里。新的復制操作都會覆蓋前一個clip對象,因為用戶可能從你的應用中退出,從其他應用中拷貝一個東西,所以你不能假定用戶在你的應用中拷貝的上一個東西一定還放在剪貼板里。
- 一個clip對象,即ClipData中的多個ClipData.Item 對象是為了支持多選項的復制粘貼,而不是為了支持單選的多種形式。你通常需要clip對象中的所有的項目,即ClipData.Item有一樣的形式,比如都是文字,都是URI或都是Intent,而不是混合各種形式。
- 當你提供數據時,你可以提供不同的MIME表達方式。將你支持的MIME類型加入到ClipDescription中去,然后在你的content provider中實現它。
- 當你從剪貼板得到數據時,你的應用有責任檢查可用的MIME類型,然后決定使用哪一個。即便有一個clip對象在剪貼板中并且用戶要求粘貼,你的應用有可能不需要進行粘貼操作。你應該在MIME類型兼容的時候執行粘貼操作。你可以選擇使用 coerceToText()方法將粘貼的內容轉換為文字。如果你的應用支持多種類型,你可以讓用戶自己選用哪一個。
參考