Content Providers拾漏

在一次用Content Providers 的过程中,发现自己对它还是有很多地方很陌生,虽然google自己也说Content Providers 是android的一大特色之一,但是如果不太熟悉它的人,用起来还是有很多地方细节不明白(很明显,我就是这类人: ) )。本文主要是翻译android文档里的一篇Content Providers文章(一些比较繁琐英文描述的就被我删除了,所以如果可以还是看看英文的好http://developer.android.com/guide/topics/providers/content-providers.html),中间再加上了自己一些的理解(当然翻译有不到味的地方,见怪莫怪: ) ),最后附上网上的几个例子说明(既然网上有好的例子干嘛留着不用,吸收多点别人的设计经验嘛: ) )。目的,主要是拾取以前的遗漏的小知识点。

 

Content Providers

Content Providers 主要是用来存储和查询数据,更重要的是所有应用程序都可以访问它。这也是程序之间分享数据的唯一方法。Android里面是没有提供共同存储的区域来给所有的包访问。

Android 系统为一些常见的数据类型(如音乐、视频、图像、手机通信录联系人信息等)内置了一系列的 Content Provider, 这些都位于android.provider包下。持有特定的许可,可以在自己开发的应用程序中访问这些Content Provider。

如果自己想提供一些公共数据供大家访问,有两个方法:创建自己的content provider 或者向已有的provider添加数据——后者前提是数据是同类型并且拥有权限去写。

如何访问Content Provider:

访问 Content Provider中的数据主要通过ContentResolver对象,ContentResolver类提供了成员方法可以用来对Content Provider 中的数据进行查询、插入、修改和删除等操作。

 

Content Provider Basics

Content provider 实际上如何存储数据是由设计者决定。但是所有的content provider都要实现一个公共接口来查询数据和返回结果——同样也有添加,更新和删除数据。

但是这样的一个接口用户用起来不方便,大多数是在实现的acitivity里或者其他应用组件使用 getContentResolver() 来获取ContentResolver 。接着你可以使用ContentResolver的方法跟你感兴趣的content provider交互。

当一个查询来到时,android system 会在系统的某个记录里找到这个查询对应的content provider,然后运行它。(所有的ContentProvider是由系统自己来实例化)。实际上,你永远不会去直接处理ContentProvider。每个ContentProvider只有一个实例,但能够跟不同程序里的多个ContentResolver 对象交流。进程之间的交互就是由ContentProvider和ContentResolver类来处理。

 

The data model

Content provider 将暴露的数据作为简单的表,它基于数据库模型,这张表每行是一个记录每列是特定类型的数据。Example:


 

_ID

NUMBER

NUMBER_KEY

      LABEL

NAME

TYPE

13

(425) 555 6677

425 555 6677

Kirkland office

Bully Pulpit

TYPE_WORK

44

(212) 555-1234

212 555 1234

NY apartment

Alan Vain

TYPE_HOME

45

(212) 555-6657

212 555 6657

Downtown office

Alan Vain

TYPE_MOBILE

53

201.555.4433

201 555 4433

Love Nest

Rex Cars

TYPE_HOME

 

每条记录都有包括一个数字 _ID 字段,他是这个表里这行记录里唯一标识。(翻译太繁琐了)简单来说就是其他表引用了这个字段,那么它可以通过这个字段来找到这个表里的具体记录。

每次查询都会返回一个Cursor 对象,它能够读取每行每列的内容。它有特殊的方法来读取每个类型的数据。所以,读取一个字段,你必须知道这个字段包含了什么数据类型。

 

Content URI (在开始之前先了解URI的具体部分和各个的意义)

分析URI:

clip_image002

A. 标准的前缀声明的数据是由content provider来控制的。它从来都不会改变

B. URI的权限部分,它是识别content provider。对于第三方应用,这个应该是一个类的全名,以保证唯一性。该权限在 <provider>元素 authorities 的属性中声明。

<provider android:name=".TransportationProvider"   
  android:authorities="com.example.transportationprovider"     . . . >

C. 路径是content provider用来决定需要什么类型的数据。它可以是0或者更长的片段。如果content provider只展示一种数据类型(例如trains),那可以缺省。如果provider展示的是一系列类型,包括子类型——例如:"land/bus", "land/train", "sea/ship", and "sea/submarine" 四种可能性,则后面需要添加片段(segment)。

D. 如果请求不是限于一条记录,那可以省略掉后面的那个片段:

content://com.example.transportationprovider/trains

 

URIs

每个content provider 暴露一个公共的URI来标识唯一的数据集。Content provider 控制多个数据集并为每一个数据集提供一个URI。所有的URI开头都是一个”content://” 的字符串。这个 content: 规范标识数据是由content provider来管理的。

定义content provider 的一个好的方法是定义一个常量给URI,这简化了客户端代码和以后升级更干净。Android 自身定义了CONTENT_URI常量给provider。

URI常量通常跟content provider 起着交互作用。每个ContentResolver里的方法都将URI作为第一个参数。因为它标识着哪个provider Contentresolver需要去交流并且指明provider的哪个表。

 

Querying a content provider

你需要三种信息来查询一个content provider:

1. 标识provider的URI

2. 你希望接收的字段名

3. 这些字段的数据类型

当然如果你查询的是某个字段,那也需要这个记录的ID

 

Making the query

查询content provider,可以使用ContentResolver.query()或者Acitivity.managedQuery()。两个方法都需要同样的参数,返回同样的Cursor对象。(managedQuery() and getContentREsolver().query()的区别:使用managedQuery()后返回cursoractivity会保持一个引用到cursor,无论什么时候都可以关闭,例如当调用onDestroy时,activity会自动调用,无需自己手动去调用close。但是如果使用query(),那就要自己去管理Cursor,如果在onDestory时忘记去close,将会泄露内存,当然可以使用 Activity.startManagingCursor()交给activity管理)。

如何给URI加入查询条件,如有ID为23,URI将会这样:

content://. . . ./23

可以使用 ContentUris.withAppendedId() and Uri.withAppendedPath(),它们使加入查询条件变简单了。Example:(两种示例,第一个是以int形式加入,第二个是以字符形式加入)

 

import android.provider.Contacts.People;
import android.content.ContentUris;
import android.net.Uri;
import android.database.Cursor;

// Use the ContentUris method to produce the base URI for the contact with _ID == 23.
Uri myPerson = ContentUris.withAppendedId(People.CONTENT_URI, 23);

// Alternatively, use the Uri method to produce the base URI.
// It takes a string rather than an integer.
Uri myPerson = Uri.withAppendedPath(People.CONTENT_URI, "23");

// Then query for this specific record:
Cursor cur = managedQuery(myPerson, null, null, null, null);
 

query() and managedQuery() 方法的参数讲解(除第一个URI参数):(结合下面例子来说)

import android.provider.Contacts.People;
import android.database.Cursor;

// Form an array specifying which columns to return. 
String[] projection = new String[] {
                                 People._ID,
                                 People._COUNT,
                                 People.NAME,
                                 People.NUMBER
                              };

 // Get the base URI for the People table in the Contacts content provider.
 Uri contacts =  People.CONTENT_URI;

 // Make the query. 
 Cursor managedCursor = managedQuery(contacts,
                             projection, // Which columns to return 
                             null,       // Which rows to return (all rows)
                             null,       // Selection arguments (none)
                             // Put the results in ascending order by name
                             People.NAME + " ASC");

1. 第二个参数,projection变量,返回的结果是该传入变量里所列举的字段名。如果该参数null则返回所有字段数据。

2. 第三个参数,where 后的参数(不包括where和数值),返回符合这些参数的值的数据,要跟第四个参数结合使用。

3. 第四个参数,where 后参数的值。

4. 排序(不包括order by)

上面的例子主要是返回联系人的数据。

 

What a query returns

接上面的例子,一个查询返回的是一系列的zero或者数据库记录。

_ID

_COUNT

NAME

NUMBER

44

3

Alan Vain

212 555 1234

13

3

Bully Pulpit

425 555 6677

53

3

Rex Cars

201 555 4433

接收到的数据是通过Cursor对象来获取,它通常可以向前和向后遍历整个结果集。但是这只能读,如果想添加、更新和删除就必须使用ContentResolver对象。

 

Reading retrieved data

你可以通过特定字段读取记录里的数据,前提是必须知道这个字段的数据类型,因为Cursor对象分开不同的方法去读取每种数据类型——例如 getString(), getInt(), and getFloat(). Cursor允许你通过该列的位置来获取某个列的名称,又或者通过该列名称或者该列的位置。

下面这个示例片段是读取Cursor里面的数据

import android.provider.Contacts.People;

    private void getColumnData(Cursor cur){ 
        if (cur.moveToFirst()) {

            String name; 
            String phoneNumber; 
            int nameColumn = cur.getColumnIndex(People.NAME); 
            int phoneColumn = cur.getColumnIndex(People.NUMBER);
            String imagePath; 
        
            do {
                // Get the field values
                name = cur.getString(nameColumn);
                phoneNumber = cur.getString(phoneColumn);
               
                // Do something with the values. 
                ... 

            } while (cur.moveToNext());

        }
    }

如果查询返回二进制数据,如图片或者声音,这个数据可能直接保存在表里或者保存的是URI,然后可通过它去拿到数据。少量数据(20-50k或者更少)通常是直接保存在表里并且可以通过getBlob()来获取,返回byte数组。

但是如果表里是URI,你永远不能直接去打开读取(因为权限的问题会导致失败)。相反,你应该调用ContentResolver.openInputStream() 获取InputStream对象来读取数据(后面有详细说明如果读)。

 

Modifying Data

数据可以通过以下方式改变

1. 添加一个新的记录

2. 添加新值到存在的记录

3. 批量更新存在的记录

4. 删除记录

所有数据的修改都是通过使用ContentResolver方法来完成。一些content provider写需要比读更严格的权限。如果没有权限去写content provider,ContentResolver方法将调用失败。

 

Adding records

向content provider添加一个新的记录,首先创建ContentValues对象,每个key对应列的名称,value就是所要插入到列的值。调用 ContentResolver.insert() 并传递这个provider所需要的URI和 ContentValues。结果将返回一个新的记录 ,它以URI形式返回——你可以使用它去查询获取新的记录然后修改记录。Example:

    import android.provider.Contacts.People;
    import android.content.ContentResolver;
    import android.content.ContentValues; 

    ContentValues values = new ContentValues();

    // Add Abraham Lincoln to contacts and make him a favorite.
    values.put(People.NAME, "Abraham Lincoln");
    // 1 = the new contact is added to favorites
    // 0 = the new contact is not added to favorites
    values.put(People.STARRED, 1);

    Uri uri = getContentResolver().insert(People.CONTENT_URI, values);

Adding new values

一旦记录存在,你可以添加新的信息或者修改存在的信息。例如,下一步在上例中将添加联系人信息--像电话号码或IM或e-mail地--给新的条目。 添加记录到联系人数据最好的方法是在URI后面加上要添加数据到该表的表名。然后使用修改过的URI添加数据。

     Uri phoneUri = null;
    Uri emailUri = null;

    // Add a phone number for Abraham Lincoln.  Begin with the URI for
    // the new record just returned by insert(); it ends with the _ID
    // of the new record, so we don't have to add the ID ourselves.
    // Then append the designation for the phone table to this URI,
    // and use the resulting URI to insert the phone number.
    phoneUri = Uri.withAppendedPath(uri, People.Phones.CONTENT_DIRECTORY);

    values.clear();
    values.put(People.Phones.TYPE, People.Phones.TYPE_MOBILE);
    values.put(People.Phones.NUMBER, "1233214567");
    getContentResolver().insert(phoneUri, values);

    // Now add an email address in the same way.
    emailUri = Uri.withAppendedPath(uri, People.ContactMethods.CONTENT_DIRECTORY);

    values.clear();
    // ContactMethods.KIND is used to distinguish different kinds of
    // contact methods, such as email, IM, etc. 
    values.put(People.ContactMethods.KIND, Contacts.KIND_EMAIL);
    values.put(People.ContactMethods.DATA, "test@example.com");
    values.put(People.ContactMethods.TYPE, People.ContactMethods.TYPE_HOME);
    getContentResolver().insert(emailUri, values);   

 

你可以通过调用ContentValues.put()放置小量的二进制数据到表中。这对小icon的图片或者短的音频有效。然后如果你要放置大量的二进制数据,如图像或者完整的歌曲,可使用URI来代替数据并且调用 ContentResolver.openOutputStream() 传入文件的URI。

就这一点而言,MediaStore content provider,它主要提供分配图片,音频和视频数据,利用特定的协议:可以通过使用URI来获取MediaStore里的二进制数据,如使用openInputStream() 来获取数据。同样,使用openOutputStream() 放置二进制数据到MediaStore。

    import android.provider.MediaStore.Images.Media;
    import android.content.ContentValues;
    import java.io.OutputStream;

    // Save the name and description of an image in a ContentValues map.  
    ContentValues values = new ContentValues(3);
    values.put(Media.DISPLAY_NAME, "road_trip_1");
    values.put(Media.DESCRIPTION, "Day 1, trip to Los Angeles");
    values.put(Media.MIME_TYPE, "image/jpeg");

    // Add a new record without the bitmap, but with the values just set.
    // insert() returns the URI of the new record.
    Uri uri = getContentResolver().insert(Media.EXTERNAL_CONTENT_URI, values);

    // Now get a handle to the file for that record, and save the data into it.
    // Here, sourceBitmap is a Bitmap object representing the file to save to the database.
    try {
        OutputStream outStream = getContentResolver().openOutputStream(uri);
        sourceBitmap.compress(Bitmap.CompressFormat.JPEG, 50, outStream);
        outStream.close();
    } catch (Exception e) {
        Log.e(TAG, "exception while writing image", e);
    }

 

Batch updating records

批量更新一组记录(如在所有的字段里将“NY”改成“new york”),调用 ContentResolver.update() 方法传入指定列和值。

Deleting a record

删除单条记录,调用 ContentResolver.delete()传入指定行的URI。

删除多条记录,调用 ContentResolver.delete()传入要删除记录的类型的URI (例如: android.provider.Contacts.People.CONTENT_URI 加上 SQL的 WHERE 来定义要删除的列)

 

Creating a Content Provider

创建content provider步骤:

1. 建立存储数据的程序系统。大多数content provider存储数据使用android的文件存储仓库或者数据库,但你可以用你所想要的方法来存储数据。Android 提供SQLiteOpenHelper类来帮助你创建数据库和SQLiteDatabase和管理它。

2. 继承扩展ContentProvider 类来提供访问数据的权限。

3. 在manifest文件中给你程序声明content provider

 

Extending the ContentProvider class

继承ContentProvider后,主要需要实现6个抽象方法:

query()
insert()
update()
delete()
getType()
onCreate()

query() 方法返回Cursor对象,它可以遍历需要的数据。Cursor本身是个接口,但是android 提供一些做好的Cursor对象供你使用。例如,SQLiteCursor能遍历存储在数据库里的数据。通过调用SQLiteDatabase类的query()方法获取Cursor。这里也有其他实现Cursor——如MatrixCursor——不过数据不是放到数据库里

因为这些ContentProvider方法能被不同的ContentResolver对象在不同进程和线程里调用,因此实现它必须以线程安全的方式。

最后,当修改数据完后你应该调用 ContentResolver.notifyChange() 来通知监听者。

在继承该类之后,你应该用以下的方式使自己的类更通俗易懂:

1. 定义 public static final URI 名称叫CONTENT_URI。你必须定义唯一的字符串作为值。最好的解决方案是使用该类的全面(都是小写)。Example:

public static final Uri CONTENT_URI = 
    Uri.parse("content://com.example.codelab.transportationprovider");

 

如果provider有子表,也需要定义CONTENT_URI常量给每个子表。这些URI有相同的权限,不同是在他们的路径部分。如:

content://com.example.codelab.transportationprovider/train 
content://com.example.codelab.transportationprovider/air/domestic
content://com.example.codelab.transportationprovider/air/international

 

2. 定义content provider返回给客户端的列的名称。

确定记录里一定要包括int类型名叫“_id”的列,不管你是否已经定义了其他的字段,但是这个字段一定要有,因为它是作为所有记录里的唯一标识。如果你使用SQLite 数据库,_ID 字段应该是下面的格式:

INTEGER PRIMARY KEY AUTOINCREMENT

3. 仔细注释每一列的数据类型,用户需要这些信息来读懂数据的意思。

4. 如果你正处理一个新的数据类型。你必须在实现ContentProvider.getType()里定义一个新的MIME类型然后返回。 (后面这段看起来挺抽象的,所以我就直接以原文放上)The type depends in part on whether or not the content: URI submitted to getType() limits the request to a specific record。MIME类型有两种方式,一种是单条记录,一种是多条记录。Example:

单个记录:

    vnd.android.cursor.item/vnd.yourcompanyname.contenttype
    //For example, a request for train record 122, like this URI,
    content://com.example.transportationprovider/trains/122
    //might return this MIME type:
    //vnd.android.cursor.item/vnd.example.rail
多个记录:
    vnd.android.cursor.dir/vnd.yourcompanyname.contenttype
    //For example, a request for all train records, like the following URI,
    content://com.example.transportationprovider/trains
    //might return this MIME type:
    //vnd.android.cursor.dir/vnd.example.rail

5. 如果你展示的byte数据太大而不能放到表里——如大的图片文件——那这个字段应该以URI字符形式展示。这个字段允许用户通过它访问文件。这个记录应该有另一个字段名叫“_data”,它列举了这个文件在设备上的精确路径。但是这个字段不能在客户端读取,只能在ContentResolver中获取。(如何读取前面已经有说明)

 

Declaring the content provider

在开发中为了系统能了解你的content provider,你应该在 AndroidManifest.xml file中声明 <provider> 标签。如果没有声明,则系统是不知道你定义了content provider。

Name 属性对应着contentprovider子类的全名,authorities 属性是URI部分中的权限。例如如果contentprovider的子类是AutoInfoProvider,下面<provider> 元素将看起来如下:

<provider android:name="com.example.autos.AutoInfoProvider"   
  android:authorities="com.example.autos.autoinfoprovider"    
  . . . /></provider>

从以上可看出authorities属性是URI部分的之一。例如:

content://com.example.autos.autoinfoprovider/honda
content://com.example.autos.autoinfoprovider/gm/compact
content://com.example.autos.autoinfoprovider/gm/suv

这些路径不会在manifest中声明。authority 只是识别provider,不是路径。因此你的provider是可以分析出任何URI里的部分路径是否具有该权限。

Other的 <provider> 属性标签具体可以查看相关文档。

 

 

///下面是示例代码,当然是引用别人的,下面有链接

    package jason.wei.apps.securenotes.providers;
     
    import jason.wei.apps.securenotes.db.Note.Notes;
     
    import java.util.HashMap;
     
    import android.content.ContentProvider;
    import android.content.ContentUris;
    import android.content.ContentValues;
    import android.content.Context;
    import android.content.UriMatcher;
    import android.database.Cursor;
    import android.database.SQLException;
    import android.database.sqlite.SQLiteDatabase;
    import android.database.sqlite.SQLiteOpenHelper;
    import android.database.sqlite.SQLiteQueryBuilder;
    import android.net.Uri;
    import android.util.Log;
     
    /**
     * @author Jason Wei
     *
     */
    public class NotesContentProvider extends ContentProvider {
     
        private static final String TAG = "NotesContentProvider";
     
        private static final String DATABASE_NAME = "notes.db";
     
        private static final int DATABASE_VERSION = 1;
     
        private static final String NOTES_TABLE_NAME = "notes";
     
        public static final String AUTHORITY = "jason.wei.apps.notes.providers.NotesContentProvider";
     
        private static final UriMatcher sUriMatcher;
     
        private static final int NOTES = 1;
     
        private static HashMap<String, String> notesProjectionMap;
     
        private static class DatabaseHelper extends SQLiteOpenHelper {
     
            DatabaseHelper(Context context) {
                super(context, DATABASE_NAME, null, DATABASE_VERSION);
            }
     
            @Override
            public void onCreate(SQLiteDatabase db) {
                db.execSQL("CREATE TABLE " + NOTES_TABLE_NAME + " (" + Notes.NOTE_ID
                        + " INTEGER PRIMARY KEY AUTOINCREMENT," + Notes.TITLE + " VARCHAR(255)," + Notes.TEXT
                        + " LONGTEXT" + ");");
            }
     
            @Override
            public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
                Log.w(TAG, "Upgrading database from version " + oldVersion + " to " + newVersion
                        + ", which will destroy all old data");
                db.execSQL("DROP TABLE IF EXISTS " + NOTES_TABLE_NAME);
                onCreate(db);
            }
        }
     
        private DatabaseHelper dbHelper;
     
        @Override
        public int delete(Uri uri, String where, String[] whereArgs) {
            SQLiteDatabase db = dbHelper.getWritableDatabase();
            int count;
            switch (sUriMatcher.match(uri)) {
                case NOTES:
                    count = db.delete(NOTES_TABLE_NAME, where, whereArgs);
                    break;
     
                default:
                    throw new IllegalArgumentException("Unknown URI " + uri);
            }
     
            getContext().getContentResolver().notifyChange(uri, null);
            return count;
        }
     
        @Override
        public String getType(Uri uri) {
            switch (sUriMatcher.match(uri)) {
                case NOTES:
                    return Notes.CONTENT_TYPE;
     
                default:
                    throw new IllegalArgumentException("Unknown URI " + uri);
            }
        }
     
        @Override
        public Uri insert(Uri uri, ContentValues initialValues) {
            if (sUriMatcher.match(uri) != NOTES) { throw new IllegalArgumentException("Unknown URI " + uri); }
     
            ContentValues values;
            if (initialValues != null) {
                values = new ContentValues(initialValues);
            } else {
                values = new ContentValues();
            }
     
            SQLiteDatabase db = dbHelper.getWritableDatabase();
            long rowId = db.insert(NOTES_TABLE_NAME, Notes.TEXT, values);
            if (rowId > 0) {
                Uri noteUri = ContentUris.withAppendedId(Notes.CONTENT_URI, rowId);
                getContext().getContentResolver().notifyChange(noteUri, null);
                return noteUri;
            }
     
            throw new SQLException("Failed to insert row into " + uri);
        }
     
        @Override
        public boolean onCreate() {
            dbHelper = new DatabaseHelper(getContext());
            return true;
        }
     
        @Override
        public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
            SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
     
            switch (sUriMatcher.match(uri)) {
                case NOTES:
                    qb.setTables(NOTES_TABLE_NAME);
                    qb.setProjectionMap(notesProjectionMap);
                    break;
     
                default:
                    throw new IllegalArgumentException("Unknown URI " + uri);
            }
     
            SQLiteDatabase db = dbHelper.getReadableDatabase();
            Cursor c = qb.query(db, projection, selection, selectionArgs, null, null, sortOrder);
     
            c.setNotificationUri(getContext().getContentResolver(), uri);
            return c;
        }
     
        @Override
        public int update(Uri uri, ContentValues values, String where, String[] whereArgs) {
            SQLiteDatabase db = dbHelper.getWritableDatabase();
            int count;
            switch (sUriMatcher.match(uri)) {
                case NOTES:
                    count = db.update(NOTES_TABLE_NAME, values, where, whereArgs);
                    break;
     
                default:
                    throw new IllegalArgumentException("Unknown URI " + uri);
            }
     
            getContext().getContentResolver().notifyChange(uri, null);
            return count;
        }
     
        static {
            sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
            sUriMatcher.addURI(AUTHORITY, NOTES_TABLE_NAME, NOTES);
     
            notesProjectionMap = new HashMap<String, String>();
            notesProjectionMap.put(Notes.NOTE_ID, Notes.NOTE_ID);
            notesProjectionMap.put(Notes.TITLE, Notes.TITLE);
            notesProjectionMap.put(Notes.TEXT, Notes.TEXT);
     
        }
    }

    public class Note {
     
        public Note() {
        }
     
        public static final class Notes implements BaseColumns {
            private Notes() {
            }
     
            public static final Uri CONTENT_URI = Uri.parse("content://"
                    + NotesContentProvider.AUTHORITY + "/notes");
     
            public static final String CONTENT_TYPE = "vnd.android.cursor.dir/vnd.jwei512.notes";
     
            public static final String NOTE_ID = "_id";
     
            public static final String TITLE = "title";
     
            public static final String TEXT = "text";
        }
     
    }

 

引用该代码的链接:http://www.haoni.org/2011/03/09/androidcontentproviderhecontentresolver/

getType方法的解析

http://blog.csdn.net/tracy4u/archive/2011/02/14/6183971.aspx

 

本文为原创翻译,如需转载,请注明作者和出处,谢谢!

出处:http://www.cnblogs.com/not-code/archive/2011/06/24/2088653.html

转载于:https://www.cnblogs.com/not-code/archive/2011/06/24/2088653.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值