《Android实例剖析笔记》系列文章

1:Provider 的创建使用

俗话说,“熟读唐诗三百首,不会作诗也会吟”。最近收集了很多Android的示例代码,从这些代码的阅读和实验中学习到 很多知识,从而产生写这个系列的打算,目标就是一步步跟着实例进行动手实作,真正从“做”中体会和学习AndroidAndroid自带的一个范例程序: 记事本,将分为四篇文章进行详细介绍。
预备知识

搭建开发环境,尝试编写”Hello World”,了解Android的基本概念,熟悉Android的API(官方文档中都有,不赘述)。
程序截图

先来简单了解下程序运行的效果
《Android实例剖析笔记》系列文章 - OPhone|Android技术讨论区 - 中国移动开发者社区论坛 OMS开发,Android开发,symbian开发,j2me开发,windows phone开发,手机软件开发,手机游戏开发,主题制作 - littlefermat - littlefermat
《Android实例剖析笔记》系列文章 - OPhone|Android技术讨论区 - 中国移动开发者社区论坛 OMS开发,Android开发,symbian开发,j2me开发,windows phone开发,手机软件开发,手机游戏开发,主题制作 - littlefermat - littlefermat
《Android实例剖析笔记》系列文章 - OPhone|Android技术讨论区 - 中国移动开发者社区论坛 OMS开发,Android开发,symbian开发,j2me开发,windows phone开发,手机软件开发,手机游戏开发,主题制作 - littlefermat - littlefermat
《Android实例剖析笔记》系列文章 - OPhone|Android技术讨论区 - 中国移动开发者社区论坛 OMS开发,Android开发,symbian开发,j2me开发,windows phone开发,手机软件开发,手机游戏开发,主题制作 - littlefermat - littlefermat







程序入口点


类 似于win32程序里的WinMain函数,Android自然也有它的程序入口点。它通过在AndroidManifest.xml文件中配置来指明, 可以看到名为NotesList的activity节点下有这样一个intent-filter,其action为 android.intent.action.MAIN,
Category指定为 android.intent.category.LAUNCHER,这就指明了这个activity是作为入口activity,系统查找到它后,就会创建这个activity实例来运行,若未发现就不启动(你可以把MAIN改名字试试)。


<intent-filter>
<action android:name="android.intent.action.MAIN"
/>
<category android:name="android.intent.category.LAUNCHER"
/>
</intent-filter>

NotesList
详解


就从入口点所在的activity(见图1)开始,可以看到这个activity最重要的功能就是显示日志列表。这个程序的日志都存放在Sqlite数据库中,因此需要读取出所有的日志记录并显示。

先来看两个重要的私有数据,第一个PROJECTION 字段指明了“日志列表“所关注的数据库中的字段(即只需要ID和Title就可以了)。

private
static
final String[] PROJECTION =
new// 0
Notes.TITLE, // 1
};


第二个字段COLUMN_INDEX_TITLE 指明title字段在数据表中的索引。
private
static
final
int COLUMN_INDEX_TITLE =
1;
然后就进入第一个调用的函数onCreate。
Intent intent =if (intent.getData() ==
null)
{
intent.setData(Notes.CONTENT_URI);
}

因为NotesList这个activity是系统调用的,此时的intent是不带数据和操作类型的,系统只是在其中指明了目标组件是 Notelist,所以这里把”content:// com.google.provider.NotePad/notes”保存到intent里面,这个URI地址指明了数据库中的数据表名(参见以后的 NotePadProvider类),也就是保存日志的数据表notes。
Cursor cursor = managedQuery(getIntent().getData(), PROJECTION, null, null, Notes.DEFAULT_SORT_ORDER);
然后调用managedQuery函数查询出所有的日志信息,这里第一个参数就是上面设置的” content:// com.google.provider.NotePad/notes”这个URI,即notes数据表。PROJECTION 字段指明了结果中所需要的字段,Notes.DEFAULT_SORT_ORDER 指明了结果的排序规则。实际上managedQuery并没有直接去查询数据库,而是通过Content Provider来完成实际的数据库操作,这样就实现了逻辑层和数据库层的分离。
SimpleCursorAdapter adapter =
new SimpleCursorAdapter(this, R.layout.noteslist_item, cursor,
new String[] { Notes.TITLE }, new
int[] { android.R.id.text1 });
setListAdapter(adapter);

查询出日志列表后,构造一个CursorAdapter,并将其作为List View的数据源,从而在界面上显示出日志列表。可以看到,第二个参数是R.layout.noteslist_item,打开对应的noteslist_item.xml文件,
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@android:id/text1"
android:layout_width="fill_parent"
android:layout_height="?android:attr/listPreferredItemHeight"
android:textAppearance="?android:attr/textAppearanceLarge"
android:gravity="center_vertical"
android:paddingLeft="5dip"
android:singleLine="true"
/>

就是用来显示一条日志记录的TextView,最后两个字段指明了实际的字段映射关系,通过这个TextView来显示一条日志记录的title字段。



既然有了“日志列表”,就自然要考虑如何处理某一条日志的单击事件,这通过重载onListItemClick方法来完成,
protected
void onListItemClick(ListView l, View v, int position, long= ContentUris.withAppendedId(getIntent().getData(), id);

String action =if (Intent.ACTION_PICK.equals(action) || Intent.ACTION_GET_CONTENT.equals(action)) {
// The caller is waiting for us to return a note selected by
// the user.The have clicked on one, so return it now.
setResult(RESULT_OK, newelse// Launch activity to view/edit the currently selected item
startActivity(new Intent(Intent.ACTION_EDIT, uri));
}
}

首先通过”content:// com.google.provider.NotePad/notes”和日志的id 号拼接得到选中日志的真正URI,然后创建一个新的Intent,其操作类型为Intent.ACTION_EDIT,数据域指出待编辑的日志URI(这 里只分析else块)。

那么,上面这句startActivity(new Intent(Intent.ACTION_EDIT , uri))执行后会发生什么事情呢?这时候Android系统就跳出来接管了,它会根据intent中的信息找到对应的activity,在这里找到的是NoteEditor这个activity,然后创建这个activity的实例并运行。

那么,Android又是如何找到NoteEditor这个对应的activity的呢?这就是intent发挥作用的时刻了。

new Intent(Intent.ACTION_EDIT, uri)

这里的Intent.ACTION_EDIT =” android.intent.action.EDIT”,另外通过设置断点,我们看下这里的uri值:


可以看到选中的日志条目的URI是:content://com.google.provider.NotePad/notes/1

然后我们再来看下Androidmanfest.xml,其中有这个provider
<provider android:name="NotePadProvider"
android:authorities="com.google.provider.NotePad"
/>

发现没有?它也有com.google.provider.NotePad,这个是content://com.google.provider.NotePad/notes/1的一部分,同时

<activity android:name="NoteEditor"
android:theme="@android:style/Theme.Light"
android:label="@string/title_note"
android:screenOrientation="sensor"
android:configChanges="keyboardHidden|orientation"
>
<!-- This filter says that we can view or edit the data of
a single note -->
<intent-filter android:label="@string/resolve_edit">
<action android:name="android.intent.action.VIEW"
/>
<action android:name="android.intent.action.EDIT"
/>
<action android:name="com.android.notepad.action.EDIT_NOTE"
/>
<category android:name="android.intent.category.DEFAULT"
/>
<data android:mimeType="vnd.android.cursor.item/vnd.google.note"
/>
</intent-filter>
<!-- This filter says that we can create a new note inside
of a directory of notes. -->
<intent-filter>
<action android:name="android.intent.action.INSERT"
/>
<category android:name="android.intent.category.DEFAULT"
/>
<data android:mimeType="vnd.android.cursor.dir/vnd.google.note"
/>
</intent-filter>
</activity>

上面第一个intent-filter中有一个action 名为android.intent.action.EDIT,而前面我们创建的Intent也正好是

Intent.ACTION_EDIT=” android.intent.action.EDIT”,想必大家已经明白是怎么回事了吧。

下面就进入activity选择机制了:
系 统从intent中获取道uri,得到了content://com.google.provider.NotePad/notes/1,去掉开始的 content:标识,得到com.google.provider.NotePad/notes/1,然后获取前面的 com.google.provider.NotePad,然后就到Androidmanfest.xml中找到authorities为 com.google.provider.NotePad的provider,这个就是后面要讲的contentprovider,然后就加载这个 content provider。

<provider android:name="NotePadProvider"
android:authorities="com.google.provider.NotePad"
/>

在 这里是NotePadProvider,然后调用NotePadProvider的gettype函数,并把上述URI传给这个函数,函数返回URI所对 应的类型(这里返回Notes.CONTENT_ITEM_TYPE,代表一条日志记录,而CONTENT_ITEM_TYPE = " vnd.android.cursor.item/vnd.google.note ")。
publicswitchcasereturncasereturndefaultthrow
new IllegalArgumentException("Unknown URI "
+
上面的sUriMatcher.match是用来检测uri是否能够被处理,而sUriMatcher .match(uri)返回值其实是由
sUriMatcher =
new UriMatcher(UriMatcher.NO_MATCH);
sUriMatcher.addURI(NotePad.AUTHORITY, "notes", NOTES);
sUriMatcher.addURI(NotePad.AUTHORITY, "notes/#", NOTE_ID);

决定的。

然后系统使用获得的" vnd.android.cursor.item/vnd.google.note "和”android.intent.action.EDIT”到androidmanfest.xml中去找匹配的activity.


<intent-filter android:label="@string/resolve_edit">
<action android:name="android.intent.action.VIEW"
/>
<action android:name="android.intent.action.EDIT"
/>
<action android:name="com.android.notepad.action.EDIT_NOTE"
/>
<category android:name="android.intent.category.DEFAULT"
/>
<data android:mimeType="vnd.android.cursor.item/vnd.google.note"
/>
</intent-filter>

正好NoteEditor这个activity的intent-filter满足上述条件,这样就找到了NoteEditor。于是系统加载这个类并实例化,运行,然后就到了NoteEditor的OnCreate函数中(见后续文章)。

小技巧

1, 在命令行中使用”adb shell”命令进入系统中,然后”cd app”进入应用程序所在目录,”rm XXX”就可以删除你指定的apk,从而去掉其在系统顶层界面占据的图标,若两次”cd data”则可以进入应用程序使用的数据目录,你的数据可以保存在这里,例如Notepad就是把其数据库放在它的databases目录下,名为 note_pad.db.
2,第一次启动模拟器会比较慢,但以后就别关闭模拟器了,修改代码,调试都不需要再次启动的,直接修改后run或debug就是。

上篇文章分析了NotesList这个Activity,并着重剖析了其中的intent机制,本文将继续上篇未完的工作,以NotesList为实例介绍Android的菜单机制(尤其是动态菜单机制)。
简介
android提供了三种菜单类型,分别为options menu,context menu,sub menu。
options menu就是通过按home键来显示,context menu需要在view上按上2s后显示。这两种menu都有可以加入子菜单,子菜单不能种不能嵌套子菜单。options menu最多只能在屏幕最下面显示6个菜单选项,称为icon menu,icon menu不能有checkable选项。多于6的菜单项会以more icon menu来调出,称为expanded menu。options menu通过activity的onCreateOptionsMenu来生成,这个函数只会在menu第一次生成时调用。任何想改变options menu的想法只能在onPrepareOptionsMenu来实现,这个函数会在menu显示前调用。onOptionsItemSelected 用来处理选中的菜单项。
context menu是跟某个具体的view绑定在一起,在activity种用registerForContextMenu来为某个view注册context menu。context menu在显示前都会调用onCreateContextMenu来生成menu。onContextItemSelected用来处理选中的菜单项。
android还提供了对菜单项进行分组的功能,可以把相似功能的菜单项分成同一个组,这样就可以通过调用setGroupCheckable,setGroupEnabled,setGroupVisible来设置菜单属性,而无须单独设置。
Options Menu
Notepad中使用了options menu和context menu两种菜单。首先来看生成options menu的onCreateOptionsMenu函数。

menu.add(0, MENU_ITEM_INSERT, 0, R.string.menu_insert)
.setShortcut('3', 'a')
.setIcon(android.R.drawable.ic_menu_add);


这是一个标准的插入一个菜单项的方法,菜单项的id为MENU_ITEM_INSERT。

有意思的是下面这几句代码:

Intent intent =
new Intent(null, getIntent().getData());
intent.addCategory(Intent.CATEGORY_ALTERNATIVE);
menu.addIntentOptions(Menu.CATEGORY_ALTERNATIVE, 0, 0new ComponentName(this, NotesList.class), null, intent, 0, null);


这 到底有何用处呢?其实这是一种动态菜单技术(也有点像插件机制),若某一个activity,其类型 是”android.intent.category.ALTERNATIVE”,数据 是”vnd.android.cursor.dir/vnd.google.note”的话,系统就会为这个activity增加一个菜单项。在 androidmanfest.xml中查看后发现,没有一个activity符合条件,所以这段代码并没有动态添加出任何一个菜单项。

为了验证上述分析,我们可以来做一个实验,在androidmanfest.xml中进行修改,看是否会动态生成出菜单项。
实验一

首先我们来创建一个新的activity作为目标activity,名为HelloAndroid,没有什么功能,就是显示一个界面。

public
class HelloAndroid extendsprotected
void onCreate(Bundle savedInstanceState) {
superthis

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="fill_parent"
android:layout_height="fill_parent"
>
<android:layout_width="fill_parent"="wrap_content" android:id="@+id/TextView01"/>
<Button android:id="@+id/Button01" android:layout_height="wrap_content" android:layout_width="fill_parent" android:text="@string/txtInfo"></Button>
</LinearLayout>


然后修改androidmanfest.xml,加入下面这段配置,让HelloAndroid满足上述两个条件:



<activity android:name="HelloAndroid" android:label="@string/txtInfo">
<intent-filter>
<action android:name="com.android.notepad.action.HELLO_TEST"
/>
<category android:name="android.intent.category.ALTERNATIVE"/>
<data android:mimeType="vnd.android.cursor.dir/vnd.google.note"
/>
</intent-filter>
</activity>

好了,运行下试试,哎,还是没有动态菜单项加入呀!

怎么回事呢?查看代码后发现,原来是onPrepareOptionsMenu搞的鬼!这个函数在 onCreateOptionsMenu之后运行,下面这段代码中,由于Menu.CATEGORY_ALTERNATIVE是指向同一个组,所以把 onCreateOptionsMenu中设置的菜单项给覆盖掉了,而由于onPrepareOptionsMenu没有给 Menu.CATEGORY_ALTERNATIVE附新值,故Menu.CATEGORY_ALTERNATIVE还是为空。


Intent intent =
new Intent(null, uri);
intent.addCategory(Intent.CATEGORY_ALTERNATIVE);
menu.addIntentOptions(Menu.CATEGORY_ALTERNATIVE, 0, 0, null, specifics, intent, 0,items);

好的,那我们暂时把上面这几句给注释掉,当然,也可以不注释这几句,在onCreateOptionsMenu中改 groupid号,即将Menu.CATEGORY_ALTERNATIVE改为Menu.first,其他的也行,但注意不要改为menu.none, 这样会覆盖掉


menu.add(0, MENU_ITEM_INSERT, 0, R.string.menu_insert)
.setShortcut('3', 'a')
.setIcon(android.R.drawable.ic_menu_add);


运行后就可以看到动态菜单出来了!

《Android实例剖析笔记》系列文章 - OPhone|Android技术讨论区 - 中国移动开发者社区论坛 OMS开发,Android开发,symbian开发,j2me开发,windows phone开发,手机软件开发,手机游戏开发,主题制作 - littlefermat - littlefermat

上面这个options menu是在NotesList界面上没有日志列表选中的情况下生成的,若先选中一个日志,然后再点”menu”,则生成的options menu是下面这样的:

《Android实例剖析笔记》系列文章 - OPhone|Android技术讨论区 - 中国移动开发者社区论坛 OMS开发,Android开发,symbian开发,j2me开发,windows phone开发,手机软件开发,手机游戏开发,主题制作 - littlefermat - littlefermat

哎,又动态增加了两个菜单项”Edit note”和”Edit title”,这又是如何动态加入的呢?这就是onPrepareOptionsMenu的功劳了。


Uri uri = ContentUris.withAppendedId(getIntent().getData(), getSelectedItemId());

Intent[] specifics =
new Intent[10] =
new Intent(Intent.ACTION_EDIT, uri);
MenuItem[] items =
new MenuItem[1];


然后为选中的日志创建一个intent,操作类型为Intent.ACTION_EDIT ,数据为选中日志的URI.于是会为选中的日志创建一个”Edit note”菜单项。


Intent intent =
new Intent(null, uri);
intent.addCategory(Intent.CATEGORY_ALTERNATIVE);
menu.addIntentOptions(Menu.CATEGORY_ALTERNATIVE, 0, 0, null, specifics, intent, 0

这几句和上面onCreateOptionsMenu函数中类似,用于动态增加菜单项,若某一个activity,其类 型是”android.intent.category.ALTERNATIVE”,数据 是”vnd.android.cursor.item/vnd.google.note”的话,系统就会为这个activity增加一个菜单项。在 androidmanfest.xml中查看后发现,TitleEditor这个activity符合条件,于是系统就为TitleEditor这个 activity动态添加一个菜单项”Edit title”。


else {
menu.removeGroup(Menu.CATEGORY_ALTERNATIVE);
}


若日志列表为空,则从菜单中删除组号为Menu.CATEGORY_ALTERNATIVE 的菜单项,只剩下”Add note”菜单项。


菜单项选中事件的处理非常简单,通过onOptionsItemSelected来完成,这里只是简单地调用 startActivity(new Intent(Intent.ACTION_INSERT , getIntent().getData()));这个intent的操作类型为Intent.ACTION_INSERT ,数据为日志列表的URI,即”content:// com.google.provider.NotePad/notes”


public
boolean onOptionsItemSelected(MenuItem item) {
switchcase// Launch activity to insert a new item
startActivity(new Intent(Intent.ACTION_INSERT, getIntent().getData()));
return
truereturn
super

Context Menu

下面介绍另一种菜单---上下文菜单,这通过重载onCreateContextMenu函数实现。

首先确认已经选中了日志列表中的一个日志,若没选择,则直接返回。Cursor指向选中的日志项。

Cursor cursor = (Cursor) getListAdapter().getItem(info.position);
if (cursor ==
null// For some reason the requested item isn't available, do nothing

return

然后,设置上下文菜单的标题为日志标题


// Setup the menu header
menu.setHeaderTitle(cursor.getString(COLUMN_INDEX_TITLE));



最后为上下文菜单增加一个菜单项



// Add a menu item to delete the note
menu.add(0, MENU_ITEM_DELETE, 0, R.string.menu_delete);



对于上下文菜单项选中的事件处理,是通过重载onContextItemSelected实现的。



switchcase// Delete the note that the context menu is for
Uri noteUri = ContentUris.withAppendedId(getIntent().getData(), info.id);
getContentResolver().delete(noteUri, null, nullreturn
truereturn
false

对 于日志的删除,首先调用ContentUris.withAppendedId(getIntent().getData(), info.id);来拼接出待删除日志的URI.然后getContentResolver().delete(noteUri, null, null);调用下层的Content Provider去删除此日志。

实验二

来做个简单实验,在上述代码基础上增加一个上下文菜单项。首先在onCreateContextMenu函数中增加一个上下文菜单项:

menu.add(0,MENU_ITEM_INSERT,0,R.string.menu_insert);


然后为其在onContextItemSelected函数中增加一个处理过程:

case MENU_ITEM_INSERT:
{
new AlertDialog.Builder(this).setIcon(R.drawable.app_notes)
.setTitle(R.string.app_name).setMessage(R.string.error_message).setPositiveButton(R.string.button_ok, newpublic
void onClick(DialogInterface dialog, int// TODO Auto-generated method stub

}

}).show();
return
true


实验结果如下:

《Android实例剖析笔记》系列文章 - OPhone|Android技术讨论区 - 中国移动开发者社区论坛 OMS开发,Android开发,symbian开发,j2me开发,windows phone开发,手机软件开发,手机游戏开发,主题制作 - littlefermat - littlefermat
《Android实例剖析笔记》系列文章 - OPhone|Android技术讨论区 - 中国移动开发者社区论坛 OMS开发,Android开发,symbian开发,j2me开发,windows phone开发,手机软件开发,手机游戏开发,主题制作 - littlefermat - littlefermat
附记

感谢Evan JIANG对前一篇文章的错误之处进行指正,
“<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
只是指明会在Launcher中显示图标,同一个apk可以在桌面上加很多的图标,分别启动内部不同的多个界面。“,实验后发现确实如此,学习了。

上一篇文章介绍了Android的菜单机制,并动手做了一个实验来探究动态菜单的实验机制。这一篇将重点介绍Activity的生命周期,通过一个简单的实验来摸索状态转换的机制,最后介绍NotePad中使用的自定义控件技术。

Activity 的生命周期

Activity 类中有许多onXXX形式的函数可以重载,比如onCreate,onStart,onStop,onPause,那么它们的调用顺序到底是如何的呢?下 面就通过一个实验来进行分析。在做这个实验之前,我们先得知道如何在Android中进行Log输出的。
我们要使用的是android.util.log类,这个类相当的简单易用,因为它提供的全是一些静态方法:

Log.v(String tag, String msg); //VERBOSE
Log.d(String tag, String msg); //DEBUG
Log.i(String tag, String msg); //INFO
Log.w(String tag, String msg); //WARN
Log.e(String tag, String msg); //ERROR


前面的tag是由我们定义的一个标识,一般可以用“类名_方法名“来定义。要在Eclipse中查看输出的log信息,需要打开Logcat(WindowàShow ViewàotheràAndroidàLogCat即可打开)



实验一



我们要做的实验非常简单,就是有两个Activity(我这里分别叫做frmLogin和hello2),t它们各自有一个button,可以从第一个跳到第二个,也可以从第二个跳回到第一个。
配置文件AndroidManifest.xml非常简单,第二个activity并没有多余的信息需要指定。


<application android:icon="@drawable/icon" android:label="@string/app_name">
<activity android:name=".frmLogin"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN"
/>
<category android:name="android.intent.category.LAUNCHER"
/>
</intent-filter>
</activity>
<activity android:name="hello2" android:label="@string/app_name">
</activity>
</application>

第一个activity的代码如下:

public
class frmLogin extendsprivate
final
static String TAG =
"FrmLogin"/** Called when the activity is first created. */public
void onCreate(Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
Log.v(TAG,"onCreate");
setContentView(R.layout.main);
thispublic
void setViewOneCommand()
{
Button btn = (Button)findViewById(R.id.btnGo);
btn.setOnClickListener(new View.OnClickListener()
{
public
void onClick(View v)
{
Intent intent =
new Intent();
intent.setClass(frmLogin.this, hello2.class);
startActivity(intent);
finish();
}
});
Button btnExit=(Button)findViewById(R.id.btnExit);
btnExit.setOnClickListener(new View.OnClickListener()
{
public
void onClick(View v)
{
frmLogin.this.finish();
}
});
}

@Override
protected
voidsuper"onDestroy"protected
voidsuper"onPause"protected
voidsuper"onRestart"protected
voidsuper"onResume"protected
voidsuper"onStart"protected
voidsuper"onStop"

我在每个onXXX方法中都加入了log方法,值得注意的一点是按钮单击事件处理函数中,在最后我调用了 finish(); 待会我会将此行注释掉进行对比实验。

第二个activitysetClass的两个参数反一下,这样就可以简单地实现在两个Activity界面中来回切换的功能了。
下面开始实验,第一个实验室从第一个activity跳到第二个activity(此时第一个关闭),然后从第二个跳回第一个(此时第二个关闭)
运行后观察LogCat,得到如下画面:
《Android实例剖析笔记》系列文章 - OPhone|Android技术讨论区 - 中国移动开发者社区论坛 OMS开发,Android开发,symbian开发,j2me开发,windows phone开发,手机软件开发,手机游戏开发,主题制作 - littlefermat - littlefermat

然 后来进行第二个实验,对代码进行调整,我们把第一个activity中的finish()注释掉,从第一个activity跳到第二个(此时第一个没有关 闭),然后第二个直接关闭(则第一个会重新来到前端),结果如图所示,可以看出调用了FrmLogin的onRestart而不是onStart,因为第 一个activity只是stop,而并没有被destory掉。
《Android实例剖析笔记》系列文章 - OPhone|Android技术讨论区 - 中国移动开发者社区论坛 OMS开发,Android开发,symbian开发,j2me开发,windows phone开发,手机软件开发,手机游戏开发,主题制作 - littlefermat - littlefermat

前 面两个实验都很好理解,可第三个实验就让我不明白了,过程如下:从第一个activity跳到第二个activity(此时第一个不关闭),然后第二个跳 回第一个(此时第二个也不关闭),然后第一个再跳回第二个(此时第一个不关闭),照上面来推断,应该还是会调用onRestart才对,可实际上它调用的 却是onStart,why???
《Android实例剖析笔记》系列文章 - OPhone|Android技术讨论区 - 中国移动开发者社区论坛 OMS开发,Android开发,symbian开发,j2me开发,windows phone开发,手机软件开发,手机游戏开发,主题制作 - littlefermat - littlefermat
这里先不讨论例子了,来看看官方文档对Activity生命周期的介绍。

1.Android用Activity Stack来管理多个Activity,所以呢,同一时刻只会有最顶上的那个Activity是处于active或者running状态。其它的Activity都被压在下面了。
2. 如果非活动的Activity仍是可见的(即如果上面压着的是一个非全屏的Activity或透明的Activity),它是处于paused状态的。在 系统内存不足的情况下,paused状态的Activity是有可被系统杀掉的。只是不明白,如果它被干掉了,界面上的显示又会变成什么模样?看来下回有 必要研究一下这种情况了。
3.几个事件的配对可以比较清楚地理解它们的关系。Create与Destroy配成一对,叫entrie lifetime,在创建时分配资源,则在销毁时释放资源;往上一点还有Start与Stop一对,叫visible lifetime,表达的是可见与非可见这么一个过程;最顶上的就是Resume和Pause这一对了,叫foreground lifetime,表达的了是否处于激活状态的过程。
4.因此,我们实现的Activity派生类,要重载两个重要的方法:onCreate()进行初始化操作,onPause()保存当前操作的结果。
除了Activity Lifecycle以外,Android还有一个Process Lifecycle的说明:
在内存不足的时候,Android是会主动清理门户的,那它又是如何判断哪个process是可以清掉的呢?文档中也提到了它的重要性排序:
1. 最容易被清掉的是empty process,空进程是指那些没有Activity与之绑定,也没有任何应用程序组件(如Services或者IntentReceiver)与之绑定 的进程,也就是说在这个process中没有任何activity或者service之类的东西,它们仅仅是作为一个cache,在启动新的 Activity时可以提高速度。它们是会被优先清掉的。因此建议,我们的后台操作,最好是作成Service的形式,也就是说应该在Activity中 启动一个Service去执行这些操作。


2.接下来就是background activity了,也就是被stop掉了那些activity所处的process,那些不可见的Activity被清掉的确是安全的,系统维持着一个 LRU列表,多个处于background的activity都在这里面,系统可以根据LRU列表判断哪些activity是可以被清掉的,以及其中哪一 个应该是最先被清掉。不过,文档中提到在这个已被清掉的Activity又被重新创建的时候,它的onCreate会被调用,参数就是onFreeze时 的那个Bundle。不过这里有一点不明白的是,难道这个Activity被killed时,Android会帮它保留着这个Bundle吗?

3.然后就轮到service process了,这是一个与Service绑定的进程,由startService方法启动。虽然它们不为用户所见,但一般是在处理一些长时间的操作(例如MP3的播放),系统会保护它,除非真的没有内存可用了。
4.接着又轮到那些visible activity了,或者说visible process。前面也谈到这个情况,被Paused的Activity也是有可能会被系统清掉,不过相对来说,它已经是处于一个比较安全的位置了。
5.最安全应该就是那个foreground activity了,不到迫不得已它是不会被清掉的。这种process不仅包括resume之后的activity,也包括那些onReceiveIntent之后的IntentReceiver实例。
在Android Application的生命周期的讨论中,文档也提到了一些需要注意的事项:因为Android应用程序的生存期并不是由应用本身直接控制的,而是由 Android系统平台进行管理的,所以,对于我们开发者而言,需要了解不同的组件Activity、Service和IntentReceiver的生 命,切记的是:如果组件的选择不当,很有可能系统会杀掉一个正在进行重要工作的进程。


自定义控件


这里主要介绍下“编辑日志”中使用的一个自定义EditText控件,它的效果如下图:


主要功能就是在文本语句之间绘制分割线。


public
static
class LinedEditText extendsprivateprivate// we need this constructor for LayoutInflater

public LinedEditText(Context context, AttributeSet attrs)
{
super(context, attrs);
mRect =
new=
new Paint();
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setColor(0x800000FFprotected
voidint count ===for (int i =
0; i < count; i++int baseline = getLineBounds(i, r);
canvas.drawLine(r.left, baseline +
1, r.right, baseline +
1super

主要工作就是重载onDraw方法,利用从TextView继承下来的getLineCount函数获取文本所占的行数,以及getLineBounds来获取特定行的基准高度值,而且这个函数第二个参数会返回此行的“外包装”值。再利用这些值绘制这一行的线条。

为了让界面的View使用自定义的EditText类,必须在配置文件中进行设置

<view xmlns:android="http://schemas.android.com/apk/res/android"

android:id="@+id/note"
android:layout_width="fill_parent"
android:layout_height="fill_parent"
android:background="@android:color/transparent"
android:padding="5dip"
android:scrollbars="vertical"
android:fadingEdge="vertical"
android:gravity="top"
android:textSize="22sp"
android:capitalize="sentences"
/>


这里class="com.example.android.notepad.NoteEditor$LinedEditText"就指明了应当使用自定义的LinedEditText类。

上篇文章介绍了Activity的生命周期,并通过一个实验来探索状态转换的机制,然后介绍了应用中使用的一个自定义控件。本文将继续分析NoteEditor这个类和以及Content Provider机制。



NoteEditor 深入分析


首先来弄清楚“日志编辑“的状态转换,通过上篇文章的方法来做下面这样一个实验,
首先进入“日志编辑“时会触发onCreate和onResume,然后用户通过Option Menu选择”Edit title”后,会触发onSaveInstanceState和onPause,最后,用户回到编辑界面,则再次触发onResume.
最终通过LogCat可以得到下图:
《Android实例剖析笔记》系列文章 - OPhone|Android技术讨论区 - 中国移动开发者社区论坛 OMS开发,Android开发,symbian开发,j2me开发,windows phone开发,手机软件开发,手机游戏开发,主题制作 - littlefermat - littlefermat

那么下面就按照上述顺序对此类进行剖析。

首先是onCreate方法,一开始先获取导致进入“日志编辑”界面的intent,分析其操作类型可得知是“编辑日志”还是“新增日志”。


final Intent intent =// Do some setup based on the action being performed.

final String action = intent.getAction();

若是“编辑日志”,则设置当前状态为“编辑”,并保存待编辑日志的URI.

mState == intent.getData();



若是“新增日志”,则设置当前状态为“新增”,并通过content provider向数据库中新增一个“空白日志”,后者返回“空白日志”的URI.


mState == getContentResolver().insert(intent.getData(), null);


然后不管是“编辑”或“新增”,都需要从数据库中读取日志信息(当然,若是“新增”,读出来的肯定是空数据)。

mCursor = managedQuery(mUri, PROJECTION, null, null, null);

最后,类似于web应用中使用的Session,这里也将日志文本保存在InstanceState中,因此,若此activity的实例此前是处于stop状态,则我们可以从它那取出它原本的文本数据.


if (savedInstanceState !=
null= savedInstanceState.getString(ORIGINAL_CONTENT);
}


第二个来分析onResume函数,首先把游标置于第一行(也只有一行)

mCursor.moveToFirst();

然 后取出“正文”字段,这时有一个比较有趣的技巧,“设置文本”并不是调用setText,而是调用的setTextKeepState,后者相对于前者有 一个优点,就是当界面此前stop掉,现在重新resume回来,那么此前光标所在位置仍然得以保存。而若使用setText,则光标会重置到行首。


String note = mCursor.getString(COLUMN_INDEX_NOTE);
mText.setTextKeepState(note);

最后,将当前编辑的正文保存到一个字符串变量中,用于当activity被暂停时使用。


if (mOriginalContent ==
null)
{
mOriginalContent =

通过前面的图可以得知,activity被暂停时,首先调用的是onSaveInstanceState函数。


outState.putString(ORIGINAL_CONTENT, mOriginalContent);

这里就仅仅将当前正编辑的正文保存到InstanceState中(类似于Session).
最后来看onPause函数,这里首先要考虑的是若activity正要关闭,并且编辑区没有正文,则将此日志删除。


if (isFinishing() && (length ==
0) &&
!mNoteOnly)
{
setResult(RESULT_CANCELED);
deleteNote();
}



ContentValues values =
newif (!mNoteOnly)
{
values.put(Notes.MODIFIED_DATE, System.currentTimeMillis());
if (mState == STATE_INSERT)
{
String title = text.substring(0, Math.min(30if (length >
30)
{
int lastSpace = title.lastIndexOf('
'if (lastSpace >
0)
{
title = title.substring(0, lastSpace);
}
}
values.put(Notes.TITLE, title);
}
}
values.put(Notes.NOTE, text);
getContentResolver().update(mUri, values, null, null

在生成Option Menu的函数onCreateOptionsMenu中,我们再一次看到下面这段熟悉的代码了:

Intent intent =
new Intent(null, getIntent().getData());
intent.addCategory(Intent.CATEGORY_ALTERNATIVE);
menu.addIntentOptions(Menu.CATEGORY_ALTERNATIVE, 0, 0,
new ComponentName(this, NoteEditor.class), null, intent, 0, null);


这种生成动态菜单的机制在这篇文章中已经介绍过了,就不赘述了。

最 后,来看下放弃日志和删除日志的实现,由于还没有接触到底层的content provider,这里都是通过getContentResolver()提供的update,delete,insert来向底层的content provider发出请求,由后者完成实际的数据库操作。


private
final
voidif (mCursor !=
nullif (mState ==// Put the original note text back into the database
mCursor.close();
mCursor =
null=
new ContentValues();
values.put(Notes.NOTE, mOriginalContent);
getContentResolver().update(mUri, values, null, nullelse
if (mState ==// We inserted an empty note, make sure to delete it
deleteNote();
}
}
setResult(RESULT_CANCELED);
finish();
}
private
final
voidif (mCursor !=
null)
{
mCursor.close();
mCursor =
null;
getContentResolver().delete(mUri, null, null""

剖析 NotePadProvider



NotePadProvider就是所谓的content provider,它继承自android.content.ContentProvider,也是负责数据库层的核心类,主要提供五个功能:

这五个功能分别对应下述五个可以重载的方法:


public
int delete(Uri uri, String selection, String[] selectionArgs)
{
return
0publicreturn
nullpublic Uri insert(Uri uri, ContentValues values)
{
return
nullpublic
booleanreturn
falsepublic Cursor query(Uri uri, String[] projection, String selection,
String[] selectionArgs, String sortOrder)
{
return
nullpublic
int update(Uri uri, ContentValues values, String selection,
String[] selectionArgs)
{
return
0

这些都要你自己实现,不同的实现就是对应不同的content-provider。但是activity使用content-provider不是直接创建一个对象,然后调用这些具体方法。

而是调用managedQuery,getContentResolver().delete,update等来实现, 这些函数其实是先找到符合条件的content-provider,然后再调用具体content-provider的函数来实现,那又是怎么找到 content-provider,就是通过uri中的authority来找到content-provider,这些都是通过系统完成,应用程序不用 操心,这样就达到了有效地隔离应用和内容提供者的具体实现的目的。


下面这三个字段指明了数据库名称,数据库版本,数据表名称。


private
static
final String DATABASE_NAME =
"note_pad.db";
private
static
final
int DATABASE_VERSION =
2;
private
static
final String NOTES_TABLE_NAME =
"notes";



实际的数据库操作其实都是通过一个私有静态类DatabaseHelper实现的,其构造函数负责创建指定名称和版本的数据库,onCreate函数则创建指定名称和各个数据域的数据表(就是简单的建表SQL语句)。onUpgrade负责删除数据表,再重新建表。


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._ID +
" INTEGER PRIMARY KEY,"
+ Notes.TITLE +
" TEXT,"
+ Notes.NOTE +
" TEXT,"
+ Notes.CREATED_DATE +
" INTEGER,"
+ Notes.MODIFIED_DATE +
" INTEGER"
+
");"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""DROP TABLE IF EXISTS notes"

在这篇文章中我们已经见识到了getType函数的用处了,也正是通过它的解析,才能区分开到底是对全部日志还是对某一条日志进行操作。


publicswitchcasereturncasereturndefaultthrow
new IllegalArgumentException("Unknown URI "
+

上面的sUriMatcher.match是用来检测uri是否能够被处理,而sUriMatcher .match(uri)返回值其实是由下述语句决定的。


sUriMatcher =
new UriMatcher(UriMatcher.NO_MATCH);
sUriMatcher.addURI(NotePad.AUTHORITY, "notes", NOTES);
sUriMatcher.addURI(NotePad.AUTHORITY, "notes/#", NOTE_ID);


sNotesProjectionMap这个私有字段是用来在上层应用使用的字段和底层数据库字段之间建立映射关系的,当然,这个程序里两处对应的字段都是一样(但并不需要一样)。


private
static HashMap<String, String>static=
new HashMap<String, String>();
sNotesProjectionMap.put(Notes._ID, Notes._ID);
sNotesProjectionMap.put(Notes.TITLE, Notes.TITLE);
sNotesProjectionMap.put(Notes.NOTE, Notes.NOTE);
sNotesProjectionMap.put(Notes.CREATED_DATE, Notes.CREATED_DATE);
sNotesProjectionMap.put(Notes.MODIFIED_DATE, Notes.MODIFIED_DATE);
}


数据库的增,删,改,查操作基本都一样,具体可以参考官方文档,这里就仅仅以删除为例进行说明。

一般可以分为三步来完成,首先打开数据库


SQLiteDatabase db = mOpenHelper.getWritableDatabase();


switchcase= db.delete(NOTES_TABLE_NAME, where, whereArgs);
breakcase= uri.getPathSegments().get(1= db.delete(NOTES_TABLE_NAME, Notes._ID +
"="
++ (!TextUtils.isEmpty(where) ?
" AND ("
+ where +
')' : ""break


getContext().getContentResolver().notifyChange(uri, null);





首先我想指出NotePad的一个bug,其实这个小bug在2月份就有人向官方报告了,参见根本就是没有用处的,因为它始终都是false,没有任何变化,所以可以删除掉。




setResult(RESULT_OK, (new Intent()).setAction(mUri.toString()));
setResult(RESULT_CANCELED);

可到底想展示什么技术呢?实际上并没有完整展现出来,这里我对其进行修改后来指明 参见)。

private
static
final
int REQUEST_INSERT =
100;//请求插入标识符
然后修改onOptionsItemSelected函数如下:
public
boolean onOptionsItemSelected(MenuItem item)
{
switchcasethis.startActivityForResult(new Intent(Intent.ACTION_INSERT, getIntent().getData()), REQUEST_INSERT);
return
truereturn
super



protected
void onActivityResult(int requestCode, intif(requestCode ==if(resultCode==RESULT_OK)
{
Log.d(TAG, "OK!!!"else
if(resultCode==RESULT_CANCELED)
{
Log.d(TAG, "CANCELED!!!"
这个系列的前四篇文章介绍了Android sdk中自带的NotePad—Snake(贪食蛇)。本文将主要介绍我对这个示例程序进行的一些修改。



游戏暂停 / 继续机制



由于原来的代码中在游戏运行时没有提供控制选项(比如暂停/继续),因此除非你死了,否则只能玩到底。我这里对代码进行一些修改,加入一个Option Menu来提供暂停/继续机制。

首先加入一个变量记录游戏当前状态

private
int mState = SnakeView.READY;

然后重载onCreateOptionsMenu函数,创建一个控制菜单项,并对其进行处理,提供暂停/继续机制。

/*
* @see android.app.Activity#onOptionsItemSelected(android.view.MenuItem)
* @Author:phinecos
* @Date:2009-08-28
*/public
boolean onOptionsItemSelected(MenuItem item)
{
switchcaseif (mState ==//此前状态是"停止",则转为"运行"
mState = SnakeView.RUNNING;
mSnakeView.setMode(SnakeView.RUNNING);
item.setIcon(android.R.drawable.ic_media_pause).setTitle(R.string.cmd_pause);
}
else
if(mState ==//此前状态是"运行",则转为“暂停"
mState = SnakeView.PAUSE;
mSnakeView.setMode(SnakeView.PAUSE);
item.setIcon(android.R.drawable.ic_media_play).setTitle(R.string.cmd_run);
}
else
if(mState ==//此前是"初始状态",则转为"运行"
mState =return
truereturn
super/*
* @see android.app.Activity#onOptionsItemSelected(android.view.MenuItem)
* @Author:phinecos
* @Date:2009-08-28
*/public
boolean onCreateOptionsMenu(Menu menu)
{
super.onCreateOptionsMenu(menu);
menu.add(0, MENU_CONTROL, 0, R.string.cmd_pause).setIcon(android.R.drawable.ic_media_pause);
return
true
修改后运行截图如下:
《Android实例剖析笔记》系列文章 - OPhone|Android技术讨论区 - 中国移动开发者社区论坛 OMS开发,Android开发,symbian开发,j2me开发,windows phone开发,手机软件开发,手机游戏开发,主题制作 - littlefermat - littlefermat

《Android实例剖析笔记》系列文章 - OPhone|Android技术讨论区 - 中国移动开发者社区论坛 OMS开发,Android开发,symbian开发,j2me开发,windows phone开发,手机软件开发,手机游戏开发,主题制作 - littlefermat - littlefermat

当然,这段代码还是有问题的,游戏刚开始时,必须先点击菜单确认,再按上方向键才能开始。(以后再来修改。。。)
穿墙贪食蛇

第二个修改是把这个普通的贪食蛇改成可以穿墙(呵呵,这样就可以不死了。。。)。想必要修改的就是撞墙检测那段代码?没错,就是下面这段!
// Collision detection
// For now we have a 1-square wall around the entire arena
if ((newHead.x <
1) || (newHead.y <
1) || (newHead.x > mXTileCount -
2)|| (newHead.y > mYTileCount -
2//撞墙
return
原来的版本是发现撞墙时就直接判定为失败,我这里做个小小的修改,让它可以穿墙而去:
private
voidboolean growSnake =
false// grab the snake by the head
Coordinate head = mSnakeTrail.get(0=
new Coordinate(1, 1=switchcase=
new Coordinate(head.x +
1breakcase=
new Coordinate(head.x -
1breakcase=
new Coordinate(head.x, head.y -
1breakcase=
new Coordinate(head.x, head.y +
1break//穿墙的处理

if (newHead.x ==
0//穿左边的墙
newHead.x = mXTileCount -
2else
if (newHead.y ==
0//穿上面的墙
newHead.y = mYTileCount -
2else
if (newHead.x == mXTileCount -
1//穿右边的墙
newHead.x =
1else
if (newHead.y == mYTileCount -
1//穿下面的墙
newHead.y =
1// 判断是否撞到自己

int snakelength =for (int snakeindex =
0; snakeindex < snakelength; snakeindex++=if (c.equals(newHead))
{
setMode(LOSE);
return// 判断是否吃掉“苹果”

int applecount =for (int appleindex =
0; appleindex < applecount; appleindex++=if (c.equals(newHead))
{
mAppleList.remove(c);
addRandomApple();
mScore++*=
0.9=
true// push a new head onto the ArrayList and pull off the tail
mSnakeTrail.add(0// except if we want the snake to grow

if (!growSnake)
{
mSnakeTrail.remove(mSnakeTrail.size() -
1int index =
0for (Coordinate c : mSnakeTrail)
{
if (index ==
0)
{
setTile(YELLOW_STAR, c.x, c.y);
}
else
{
setTile(RED_STAR, c.x, c.y);
}
index++
其实修改后的代码非常简单,就是把新节点的值做些处理,让它移动到对应的行/列的头部或尾部即可。

下面就是修改后的“穿墙”贪食蛇的运行截图:
《Android实例剖析笔记》系列文章 - OPhone|Android技术讨论区 - 中国移动开发者社区论坛 OMS开发,Android开发,symbian开发,j2me开发,windows phone开发,手机软件开发,手机游戏开发,主题制作 - littlefermat - littlefermat



全屏机制




游戏一般都是全屏的,原始代码也考虑到标题栏太过难看了,于是使用下面这句代码就去掉了标题栏:


requestWindowFeature(Window.FEATURE_NO_TITLE);
可还是没有达到全屏的效果,在Android1.5中实现全屏效果非常简单,只需要一句代码即可实现:

getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN);

运行效果如下图所示:
《Android实例剖析笔记》系列文章 - OPhone|Android技术讨论区 - 中国移动开发者社区论坛 OMS开发,Android开发,symbian开发,j2me开发,windows phone开发,手机软件开发,手机游戏开发,主题制作 - littlefermat - littlefermat
接下来的修改目标是加入得分排行榜机制,再加入一个启动画面和选择菜单。当然,这一篇文章水了点,仅仅记录了自己做的一些小实验,并没有涉及到Snake的代码分析,不过请继续期待下一篇文章。。。

上一篇文章介绍了我对Snake这个示例程序进行的一些简单修改,从这一篇开始真正开始详细分析Snake的具体实现。


本文首先分析Snake的界面Layout实现,并通过一个实验来说明项目中使用的FrameLayout和RelativeLayout这两种布局的效果,其次还介绍了一个用于UI优化的工具---hierarchyviewer。


1, FrameLayout



先 来看官方文档的定义:FrameLayout是最简单的一个布局对象。它被定制为你屏幕上的一个空白备用区域,之后你可以在其中填充一个单一对象 — 比如,一张你要发布的图片。所有的子元素将会固定在屏幕的左上角;你不能为FrameLayout中的一个子元素指定一个位置。后一个子元素将会直接在前 一个子元素之上进行覆盖填充,把它们部份或全部挡住(除非后一个子元素是透明的)。

有点绕口而且难理解,下面还是通过一个实例来理解吧。 我们仿照Snake项目中使用的界面一样,建立一个简单的FrameLayout,其中包含两个Views元素:ImageView和TextView, 而后面的TextView还包含在一个RelativeLayout中。

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="fill_parent"
android:layout_height="fill_parent">
<android:layout_width="fill_parent"
android:layout_height="fill_parent"="center" android:src="@drawable/img0"/>
<android:layout_width="fill_parent"
android:layout_height="fill_parent"
>
<android:text="Hello Android"
android:visibility="visible"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:gravity="center_horizontal"
android:textColor="#ffffffff"
android:textSize="24sp"/>
</RelativeLayout>
</FrameLayout>



效果如下图所示:

《Android实例剖析笔记》系列文章 - OPhone|Android技术讨论区 - 中国移动开发者社区论坛 OMS开发,Android开发,symbian开发,j2me开发,windows phone开发,手机软件开发,手机游戏开发,主题制作 - littlefermat - littlefermat


2 UI 优化

Android的tools目录下提供了许多实用工具,这里介绍其中一个用于查看当前UI结构视图的工具hierarchyviewer。

打开tools/hierarchyviewer.bat后,查看上面这个示例的UI结构图可得:

我 们可以很明显的看到由红色线框所包含的结构出现了两个framelayout节点,很明显这两个完全意义相同的节点造成了资源浪费(这里可以提醒大家在开 发工程中可以习惯性的通过hierarchyViewer查看当前UI资源的分配情况),那么如何才能解决这种问题呢(就当前例子是如何去掉多余的 frameLayout节点)?这时候就要用到<merge />标签来处理类似的问题了。我们将上边xml代码中的framLayout替换成merge:

<mergexmlns:android="http://schemas.android.com/apk/res/android">
<android:layout_width="fill_parent"
android:layout_height="fill_parent"="center" android:src="@drawable/img0"/>
<android:layout_width="fill_parent"
android:layout_height="fill_parent"
>
<android:text="Hello Android"
android:visibility="visible"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
a
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值