Android最佳實踐之UI篇
引子
不管進行什麼開發,桌面也好、移動端也罷,UI一直都是讓人頭大的一部分。那對於Android開發來說,在UI這一塊,是否有什麼最佳實踐能讓人少走一些彎路嗎?這兩天就這個問題搜了一圈,收穫了不少。
UI最佳實踐的N條建議
1. 避免嵌套過多層級的佈局
即使使用的全都是官方提供的基礎佈局和控件,也不意味着就能做出高效的UI佈局設計。每個佈局(layout),控件(Button、TextView等),都需要進行初始化,測量大小、定位以及繪製。佈局裏嵌套了過多的層級將帶來相當大的性能開銷。官方提供了Hierarchy Viewer工具來幫助我們查找可能的優化點。Hierarchy Viewer的使用方式這裏就不作介紹了,官方文檔說得很清楚。通過它我們能查看各個頁面的佈局層次和以及各個步驟(測量、定位、繪製)的耗時,並根據這些數據做出相應的優化。(使用文檔傳送門)
使用線性佈局(LinearLayout)來組織界面是導致層級過多的主要原因,由於這種組織方式相當直觀,因此深受新手的喜愛。爲了避免這部分的開銷,一般使用相對佈局(RelativeLayout)來重組界面。使用相對佈局能夠很方便的將界面由層次多、每層控件少的狹長式樹形機構,轉換成層次少、每層控件多的扁平式樹形結構。從而得到可觀的性能提升。
2. 避免使用layout_weight屬性
layout_weight屬性能夠讓我們根據實際設備的界面大小來動態的調整控件的尺寸。但在Android系統的實現上,對每個指定了layout_weight屬性的佈局、控件,系統都會執行兩次的測量計算。這個問題看起來似乎沒什麼,但在需要重複解析渲染控件的場合(ListView, GridView)將會由於重複計算變得更加嚴峻。因此要避免使用layout_weight屬性。
那麼在平時需要使用到layout_weight屬性的場合,如何將layout_weight屬性優化掉呢?舉個例子:假如需要在水平方向上有兩個按鈕(Button),我們想要讓他們的寬度都是總寬度的一半。
使用layout_weight的做法是這樣的:
- <LinearLayout
- android:width="match_parent"
- android:height="wrap_content"
- android:orientation="horizontal"
- >
- <Button
- android:width="0dp"
- android:height="wrap_content"
- android:weight="1"
- />
- <Button
- android:width="0dp"
- android:height="wrap_content"
- android:weight="1"
- />
- </LinearLayout>
使用RelativeLayout的做法是這樣的:
- <RelativeLayout
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- >
- <View
- android:id="@+id/divider"
- android:layout_width="0dp"
- android:layout_height="0dp"
- android:layout_centerHorizontal="true"
- />
- <Button
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_toLeftOf="@+id/divider"
- android:layout_alignParentLeft="true"
- />
- <Button
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_toRightOf="@+id/divider"
- android:layout_alignParentRight="true"
- />
- </RelativeLayout>
是不是很簡單呢?
3. 使用Lint工具來幫你找到可能的優化點
Lint工具是Android官方提供的一個優化點掃描工具,它會在每次構建APK包的時候運行。它會根據預設的規則,給出相應的優化建議。舉幾個例子:
- 使用合適的Drawable:一個包含了ImageView和TextView的LinearLayout,用一個複合的Drawable來替代將會更加高效。
- 用merge標籤替代根節點是幀佈局(FrameLayout)的佈局:如果一個FrameLayout是一個佈局文件的根佈局,且沒有設置內邊距或背景等屬性,那麼可以用merge標籤來代替。這會稍微提升下性能。
- 移除無用的葉節點:如果一個佈局沒有子佈局、沒有子控件,也沒有設置背景,那麼這個佈局將會是不可見的,因此也是可以移除的。
- 移除無用的父節點:如果一個佈局(1)不是ScrollView、(2)不是根節點、(3)只有一個子節點、(4)沒有設置背景,那麼它的子節點可以直接提取到這個父節點的層級上,代替父節點,以便得到一個更加扁平和高效的佈局結構。
- 層級過多的佈局:層級過身將導致糟糕的性能。儘可能的使用RelativeLayout和GridLayout,讓佈局扁平化。佈局層次的最大限制是10層。
4. 重用可重用的佈局
將在多個佈局中會用到的部分抽離出來放在一個xml文件中。然後使用include標籤來導入這個佈局。抽離出來的佈局文件的根節點佈局就是你希望它導入其他佈局文件之後出現在那個位置的佈局,如果不需要這樣一個佈局,則可以用merge標籤作爲根節點。這兩種有什麼不同呢?舉個例子:
- <!-- 假設佈局文件叫做common_layout.xml -->
- <LinearLayout
- xmlns:android="http://schemas.android.com/apk/res/android"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:orientation="horizontal"
- >
- <Button
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- />
- <Button
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- />
- </LinearLayout>
- <!-- 假設佈局文件叫做common_widget.xml -->
- <merge
- xmlns:android="http://schemas.android.com/apk/res/android"
- >
- <Button
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- />
- <Button
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- />
- </merge>
假如導入common_layout.xml,標籤是這樣的:
- <LinearLayout
- xmlns:android="http://schemas.android.com/apk/res/android"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- >
- <include layout="@layout/common_layout" />
- </LinearLayout>
導入之後,include標籤區域實際上會被common_layout.xml裏從LinearLayout開始的全部佈局、控件代替。
那如果導入common_widget.xml呢?導入的標籤跟上面一樣,只不過layout屬性的值換成了@layout/common_widget:
- <LinearLayout
- xmlns:android="http://schemas.android.com/apk/res/android"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- >
- <include layout="@layout/common_widget" />
- </LinearLayout>
導入之後,include標籤實際上會被兩個Button代替,也就是使用merge標籤的話,導入的時候merge標籤會被忽略,merge標籤下的控件會被直接放置在文件之中。
還有另一種重用佈局的方式:使用fragment。碎片(fragment)是Android 3.0以後引進的一個類似Activity的組件。相比Activity,它更加的輕量,啓動和加載更加的快,而且它可以根據需要加載和切換,並且不需要在AndroidManifest.xml文件中聲明就能使用,但它必須依賴於Activity才能使用。顧名思義,fragment能夠用來組織“小”塊的佈局,但除此之外,它還能封裝一系列的邏輯。因此對於可以重用的佈局,比如自定義的對話框,可以使用Fragment來組織管理,方便在代碼中重用。
5. 根據需要來加載佈局
有些佈局內容(如進度條指示器,某個按鈕點擊後纔會出現的額外內容等)並不需要一開始就顯示在界面上,一般在開發中會將其可見性設置爲invisible或者gone,在需要時候再設置爲visible。雖然一開始這些內容以及沒顯示在界面上了,但實際上在界面初始化的時候,這些內容還是會被加載的。對於這種狀況,使用ViewStub標籤再適合不過了。
首先將需要動態加載的佈局抽取出來到一個xml文件中。不過ViewStub不支持merge標籤,這點要注意。假設抽取出來的文件是common_layout.xml,然後將ViewStub裏的android:layout屬性指向這個文件。如下示例代碼:
- <ViewStub
- android:id="@+id/viewstub"
- android:inflatedId="@+id/inflated_layout"
- android:layout="@layout/common_layout"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- />
像上面這樣,xml中就定義完畢啦。inflatedId屬性稍後再解釋。那代碼中怎麼使用呢?同樣很簡單。有兩種方式:
- // 方式一
- findViewById(R.id.viewstub).setVisibility(View.VISIBLE);
- // 方式二
- View view = ((ViewStub) findViewById(R.id.viewstub)).inflate();
通過以上調用,就能把界面給加載出來了。inflatedId屬性的值將會在加載完畢之後賦值給被加載頁面的根節點的id屬性,在這個例子裏是common_layout裏的最外層LinearLayout。
爲什麼需要這個屬性呢?事情是這樣的。ViewStub在加載完畢之後就從界面上消失了,這意味着通過ViewStub的id再也無法索引到這個ViewStub了。那麼如果我們需要去動態調整加載進來的界面的控件該怎麼辦呢?inflatedId這個屬性就是幫我們找到這個佈局的關鍵了。如果使用的是方法二來加載頁面,inflate()方法調用將會返回加載出來的佈局的View給你,通過這個View也是可以操作加載進來的頁面的。
6. 使用後臺線程,讓ListView流暢滾動
有經驗的開發者經常建議:不要在主線程進行耗時的操作。但對於初學者來說,到底什麼是主線程呢?這個問題我琢磨了很久。主線程其實就是UI線程,這個線程負責處理跟UI相關的操作。界面的繪製、界面的加載、控件的調整等操作都是由主線程來執行的。UI線程之所以被稱作主線程,是因爲在絕大多數情況下,我們寫的代碼都會在跑在UI線程裏,比如界面的各個生命週期的回調方法(onCreate(),onResume(), onPause()等)。
由於主線程處理跟UI相關的操作,如果主線程處理耗時的操作(文件處理、網絡請求等),那麼就會導致主線程無法及時處理其他的UI操作請求,這樣我們在界面上就會明顯的感覺到卡頓。這個問題在處理ListView這種需要重複的繪製列表元素的情況下會變得更加嚴峻。因此需要小心處理ListAdapter裏getView()方法裏可能存在的耗時操作。
但使用後臺線程就要接觸多線程了,而多線程操作又是衆所周知的坑多。如果不想自己手動去維護自己的線程池或實現自己的多線程機制,那麼可以使用AsyncTask類來實現後臺處理任務的需求。這是Android提供的一個非常便捷的方法。
- // 使用AsyncTask在後臺加載一張加載很慢的圖片
- // 這裏的三個泛型參數的意義分別是:
- // 1) UI控件、2)進度回調的類型、3)任務執行完畢後的返回類型
- new AsyncTask<ViewHolder, Void, Bitmap>() {
- // ViewHolder技術是實現複雜ListView的一個優化性能的技術,後面會介紹
- private ViewHolder v;
- // doInBackground()方法在後臺線程執行
- @Override
- protected Bitmap doInBackground(ViewHolder... params) {
- v = params[0]; // 這裏保存一份界面的引用
- // 這裏假定有一個圖片加載器,getImage()方法是它的一個耗時方法
- return mFakeImageLoader.getImage();
- }
- // onPostExecute()方法在主線程執行,確保所有耗時的操作在doInBackground()方法做完了
- @Override
- protected void onPostExecute(Bitmap result) {
- super.onPostExecute(result);
- if (v.position == position) {
- v.progress.setVisibility(View.GONE);
- v.icon.setVisibility(View.VISIBLE);
- v.icon.setImageBitmap(result);
- }
- }
- }.execute(holder);
從Android 3.0(API 11)開始,AsyncTask增加了一個方法:AsyncTask.executeOnExecutor()。這個方法會利用處理器的多核特性,進一步提升性能。這個方法的具體效果取決於具體設備的處理器核心數。
7. 使用ViewHolder技術來優化ListView
前面說到,在ListView這種需要重複的繪製列表元素的情況下,性能問題將變得更加嚴峻。由於內存的原因,ListView只有一個固定數量的View列表來顯示列表的每一項,通過回收不可見的項,重新調整控件來顯示新出現在界面上的項。因此在ListView的滾動過程中,將會頻繁的調用ListAdapter.getView()方法。通過上面的優化建議,我們已經把耗時操作放到後臺線程去執行了,只把必須的UI操作留在主線程。那還能不能再優化呢?答案是肯定的。
如果有經常在網上查找教程,那麼應該對ListView的教程裏的ViewHolder技術不陌生。這個技術的核心是減少主線程裏的執行步驟,以達到優化性能的目的。通過使用ViewHolder,緩存每個View的引用,減少不必要的查找控件操作,以達到優化ListView性能的效果。
首先需要創建一個ViewHolder類來持有View的引用:
- static class ViewHolder {
- TextView text;
- TextView timestamp;
- ImageView icon;
- ProgressBar progress;
- }
然後實例化ViewHolder併爲其賦值,再將ViewHolder存儲在view的tag裏:
- ViewHolder holder = new ViewHolder();
- holder.text = (TextView) convertView.findViewById(R.id.list_item_text);
- holder.timestamp = (TextView) convertView.findViewById(R.id.list_item_timestamp);
- holder.icon = (ImageView) convertView.findViewById(R.id.list_item_icon);
- holder.progress = (ProgressBar) convertView.findViewById(R.id.list_item_progress);
- convertView.setTag(holder);
之後就可以從convertView的tag裏取出ViewHolder來直接訪問對應的控件進行操作了。
- ViewHolder holder = (ViewHolder) convertView.getTag();
- holder.text.setText(text);
- // ...這裏省略對其他控件的操作
8. 爲不同尺寸、像素密度的設備提供對應分別率的圖片資源
這個在學習Android工程目錄結構的時候就應該有所瞭解。現在一般會提供mdpi、hdpi、xhdpi、xxhdpi四種大小的資源圖片,這樣就能保證你的應用在絕大部分設備上的擁有良好的圖片顯示效果。需要注意的是ldpi這個規格已經廢棄了,不需要再提供這個大小的資源。如果你的程序會運行在比xxhdpi更大更精細尺寸的設備,可以考慮再提供一個xxxhdpi尺寸的資源文件,否則以上四種就足夠了。
9. 爲自定義控件的不同狀態提供合適的表現
如果你實現了自己的控件,那麼很有必要創建一個drawable爲控件可能處於的狀態提供對應的表現。這些表現是用戶和控件交互能獲得的直觀反饋,設置控件不同狀態下對應的表現的drawable文件大致如下:
- <?xml version="1.0" encoding="utf-8" ?>
- <selector xmlns:android="http://schemas.android.com/apk/res/android">
- <!-- 默認時的背景圖片 -->
- <item android:drawable="@drawable/button_default" />
- <!-- 沒有焦點時的背景圖片 -->
- <item
- android:state_focused="false"
- android:drawable="@drawable/button_default" />
- <!-- 非觸摸模式下獲得焦點並單擊時的背景圖片 -->
- <item
- android:state_focused="true"
- android:state_pressed="true"
- android:drawable= "@drawable/button_pressed" />
- <!-- 觸摸模式下單擊時的背景圖片 -->
- <item
- android:state_focused="false"
- android:state_pressed="true"
- android:drawable="@drawable/button_pressed" />
- <!--選中時的圖片背景 -->
- <item
- android:state_selected="true"
- android:drawable="@drawable/button_selected" />
- <!--獲得焦點時的圖片背景 -->
- <item
- android:state_focused="true"
- android:drawable="@drawable/button_selected" />
- </selector>
10. 使用字體
Android系統自帶了兩種字體:Droid Sans和Roboto。其中Roboto字體是Android 4.0之後添加的字體,這款字體更加緊湊,在小屏幕設備上也有很好的顯示效果。Android也支持使用TTF格式的自定義的字體,可以將字體放置在Asset文件夾下,也可以通過互聯網下載來使用自定義字體。放在Asset文件夾下的字體由於會打包到安裝包中,因此會稍微增大安裝包體積,假如使用的是中文字體,那就更大了。如果只是程序的某些文字需要用到一些特殊的字體,可以考慮精簡字體庫,或者使用圖片來代替。使用自定義字體的代碼如下:
- Typeface font = Typeface.createFromAsset(getAssets(), "my_font.ttf");
- ((TextView)findViewById(R.layout.MyTextView)).setTypeface(font);
11. 使用點九圖來適應不同大小的控件
點九圖是一種特殊處理過的png圖片,系統能夠根據需要縮放圖片的大小,去適應不同大小的控件。比如QQ和微信的聊天對話框裏的氣泡效果,長文字和短文字的氣泡背景的顯示效果都很好,這就是點九圖能夠實現的效果。
TODO 如何製作點九圖
12. 對於簡單的圖形圖片,儘可能使用向量圖形來繪製。
Android提供了一種機制,允許通過xml來繪製簡單的圖形。因此對於簡單的圖形圖片,如果可能使用xml來繪製它。由於繪製出來的圖形是基於矢量的,因此這個圖形文件擁有良好的伸縮性,同時也能夠減少內存的佔用。圖片資源對內存的消耗很明顯,如果使用圖片過多的話,程序將會使用很多的內存來緩存圖片,而且很容易引起OOM(OutOfMemory)錯誤。
TODO 通過xml繪製基礎的圖形
13. 巧妙使用android:tint屬性來改變圖片顏色
Android 5.0提供了一個新的屬性android:tint。這個屬性允許我們設置一個疊加顏色來改變圖片的顏色,效果類似於在PhotoShop裏設置ColorOverlay屬性。那麼對於Android 5.0之前的機器,由於沒有這個屬性,就無法實現同樣的效果了嗎?不是這樣的,雖然xml屬性沒有,但我們可以通過代碼來改變它。
- // 這段代碼實現了將support v7包提供的返回按鈕圖片設置爲白色的功能
- Drawable drawable = getDrawable(R.drawable.abc_ic_ab_back_mtrl_am_alpha);
- drawable.setColorFilter(getResources().getColor(R.color.white), PorterDuff.Mode.SRC_IN);
有了這種方式,就可以通過使用一張圖片來變化出不同顏色的效果。再也不需要把爲同一張圖片生成不同顏色的版本,在放置在資源文件夾裏管理了。
14. 使用樣式(style、dimen、color、string)將佈局文件和樣式剝離
Android裏的樣式有點像CSS裏的類。樣式允許我們將一組屬性集合起來,並指定一個名字,然後在其他地方通過引用這個名字來使用這組效果。樣式還允許繼承,然後通過重寫父樣式的某些屬性來覆蓋父樣式的屬性。這種理念遵循了一個很古老的編碼原則(DRY: Don't Repeat Yourself)。散落一地的代碼將會爲你維護代碼的工作帶來額外的挑戰。
舉個例子,在編寫xml過程中,layout_width和layout_height是兩個必不可少的屬性。我們可以通過定義如下四個樣式來減少我們的代碼書寫量。
- <style name="Match">
- <item name="android:layout_width">match_parent</item>
- <item name="android:layout_height">match_parent</item>
- </style>
- <style name="Wrap">
- <item name="android:layout_width">wrap_content</item>
- <item name="android:layout_height">wrap_content</item>
- </style>
- <style
- name="MatchHeight"
- parent="Match">
- <item name="android:layout_width">wrap_content</item>
- </style>
- <style
- name="MatchWidth"
- parent="Match">
- <item name="android:layout_height">wrap_content</item>
- </style>
記住:保持佈局xml文件的可維護性的祕訣是吧樣式屬性和定位屬性區分開來。
15. 使用主題
主題是一系列決定應用程序外觀樣式的集合。系統內置了多重主題,如Android 4.0推出的Holo主題,Android 5.0推出的MaterialDesign。
基於主題做適當的定製化,可以使應用既具備自己的個性,又不會和系統的整體體驗相差太遠。主題在樣式(style)文件裏重寫,在AndroidManifest.xml指定,根據需要你可以設定爲全應用範圍生效(在application標籤的android:theme屬性指定),也可以設置爲某一個Activity內生效(在activity標籤的android:theme屬性指定)。
另外需要注意的是,系統根據資源文件裏的標籤(resources下的style、selector、color等)來識別資源文件裏定義的資源,而不是根據資源文件的文件名來定位。因此可以放心組織你的資源到不同的資源文件中去。
16. 保持資源的名字結構清晰、意思明確
- 對於id資源,可採用這樣的命名方式:哪個頁面哪種控件代表含義,如:login_edt_username表示login頁面下一個表示用戶名的EditText。
- 對於圖片(drawable)資源,可以採用這樣的命名方式:類型哪種控件含義,如:ic_ab_edit表示這是一個表示便捷的ActionBar圖標(ic表示icon,ab表示ActionBar)。對於普通圖片標,則可以把中間的哪種控件省略。
- 對於顏色(color)資源,一般用作調色板使用,推薦採用通用的指代顏色的名稱命名。對於主題相關的再額外定義一個文件,指向這些顏色。
17. 對在不同工程中通用的資源,採用通用的命名,以便在不同工程中重用這部分的資源。
例如通用的顏色資源:
- <?xml version="1.0" encoding="utf-8">
- <resources>
- <color name="primarycolor_dark">#0e5a83</color>
- <color name="primarycolor">#0680c3</color>
- <color name="primarycolor_light">#489dca</color>
- <color name="secondary_light">#999999</color>
- <color name="secondary">#565656</color>
- <color name="secondary_dark">#4b4b4b</color>
- <color name="secondary_extraDark">#231f20</color>
- <color name="highlight_one">#35791d</color>
- <color name="highlight_two">#ff5151</color>
- <color name="main_bg">#ecf0f3</color>
- <color name="button_pressed">#036194</color>
- <color name="button_default">#0680c3</color>
- <color name="button1_pressed">#c04033</color>
- <color name="button1_default">#df4534</color>
- <color name="button2_pressed">#279030</color>
- <color name="button2_default">#37ab41</color>
- <color name="button3_pressed">#4f4f4f</color>
- <color name="button3_default">#757575</color>
- <color name="line_bg">#d6d6d8</color>
- </resources>
以及通用的字體字號資源:
- <?xml version="1.0" encoding="utf-8">
- <resources>
- <dimen name="text_mirco">12sp</dimen>
- <dimen name="text_ultraMini">13sp</dimen>
- <dimen name="text_mini">14sp</dimen>
- <dimen name="text_ultraSmall">16sp</dimen>
- <dimen name="text_small">17sp</dimen>
- <dimen name="text_moderate1">18sp</dimen>
- <dimen name="text_moderate2">19sp</dimen>
- <dimen name="text_moderate3">21sp</dimen>
- <dimen name="text_big">25sp</dimen>
- <dimen name="text_extraBig">31sp</dimen>
- </resources>
這裏爲了直觀,顏色資源直接使用了#FFFFFF形式的值。
18. 使用Toolbar、ActionBar或者支持庫(support library)提供的同等控件
如果你的應用的界面使用了包含ActionBar的設計,那麼使用SDK提供的Toolbar或ActionBar來實現。不要重複發明輪子,記住這個編程原則。安卓支持庫v7(support library v7)爲適配Android 2.1+的系統提供了支持。如果你使用ActionBar,那麼使用ActionBar樣式生成器來方便的定製化它。生成器地址(傳送門)
19. 讓系統幫你完成適配。
Android系統提供了自動根據設備顯示能力使用最合適的資源的能力。基本所有的資源都支持通過一定規則來動態切換。如可以通過提供一個res/layout-small/文件夾來爲小尺寸的設備提供定製化的佈局。