1. 程式人生 > 其它 >《第一行程式碼:Android篇》學習筆記(四)

《第一行程式碼:Android篇》學習筆記(四)

本文和接下來的幾篇文章為閱讀郭霖先生所著《第一行程式碼:Android(篇第2版)》的學習筆記,按照書中的內容順序進行記錄,書中的Demo本人全部都做過了。
每一章節本人都做了詳細的記錄,以下是我學習記錄(包含大量書中內容的整理和自己在學習中遇到的各種bug及解決方案),方便以後閱讀和查閱。最後,感激感激郭霖先生提供這麼好的書籍。

第4章 手機平板要兼顧——探究碎片

平板電腦和手機最大的區別就在於螢幕的大小,一般手機螢幕的大小會在3英寸到6英寸之間,而一般平板電腦螢幕的大小會在7英寸到10英寸之間。

螢幕大小差距過大有可能會讓同樣的介面在視覺效果上有較大的差異,比如一些介面在手機上看起來非常美觀,但在平板電腦上看起來就可能會有控制元件被過分拉長、元素之間空隙過大等情況。作為一名專業的Android開發人員,能夠同時兼顧手機和平板的開發是我們必須做到的事情。Android自3.0版本開始引入了碎片的概念,它可以讓介面在平板上更好地展示。

4.1 碎片是什麼

碎片(Fragment)是一種可以嵌入在活動當中的UI片段,它能讓程式更加合理和充分地利用大螢幕的空間,因而在平板上應用得非常廣泛。

它和活動實在是太像了,同樣都能包含佈局,同樣都有自己的生命週期。你甚至可以將碎片理解成一個迷你型的活動,雖然這個迷你型的活動有可能和普通的活動是一樣大的。

那麼究竟要如何使用碎片才能充分地利用平板螢幕的空間呢?想象我們正在開發一個新聞應用,其中一個介面使用RecyclerView展示了一組新聞的標題,當點選了其中一個標題時,就開啟另一個介面顯示新聞的詳細內容。如果是在手機中設計,我們可以將新聞標題列表放在一個活動中,將新聞的詳細內容放在另一個活動中,如圖所示:

可是如果在平板上也這麼設計,那麼新聞標題列表將會被拉長至填充滿整個平板的螢幕,而新聞的標題一般都不會太長,這樣將會導致介面上有大量的空白區域,如圖:

因此,更好的設計方案是將新聞標題列表介面和新聞詳細內容介面分別放在兩個碎片中,然後在同一個活動裡引入這兩個碎片,這樣就可以將螢幕空間充分地利用起來了,如圖:

4.2 碎片的使用方式

碎片通常都是在平板開發中使用的,新建一個FragmentTest專案,建立一個平板模擬器,開始我們的碎片探索之旅吧。效果如圖:

4.2.1 碎片的簡單用法

最簡單的碎片示例來練練手,在一個活動當中新增兩個碎片,並讓這兩個碎片平分活動空間。新建一個左側碎片佈局left_fragment.xml,程式碼如下所示:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <Button
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/button"
        android:layout_gravity="center_horizontal"
        android:text="Button"/>
</LinearLayout>

只放置了一個按鈕,並讓它水平居中顯示。然後新建右側碎片佈局right_fragment.xml,程式碼如下所示:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:background="#00ff00"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:textSize="20sp"
        android:text="This is right fragment"/>
</LinearLayout>

將這個佈局的背景色設定成了綠色,並放置了一個TextView用於顯示一段文字。

接著新建一個LeftFragment類,並讓它繼承自Fragment。

(書中所述,:“注意,這裡可能會有兩個不同包下的Fragment供你選擇,一個是系統內建的android.app.Fragment,一個是support-v4庫中的android.support.v4.app.Fragment。這裡我強烈建議你使用support-v4庫中的Fragment,因為它可以讓碎片在所有Android系統版本中保持功能一致性。

比如說在Fragment中巢狀使用Fragment,這個功能是在Android 4.2系統中才開始支援的,如果你使用的是系統內建的Fragment,那麼很遺憾,4.2系統之前的裝置執行你的程式就會崩潰。而使用support-v4庫中的Fragment就不會出現這個問題,只要你保證使用的是最新的support-v4庫就可以了。另外,我們並不需要在build.gradle檔案中新增support-v4庫的依賴,因為build.gradle檔案中已經添加了appcompat-v7庫的依賴,而這個庫會將support-v4庫也一起引入進來。”)

實際上,android.support.v4.app.Fragment 沒有;android.app.Fragment 過時;用的是import androidx.fragment.app.Fragment;

現在編寫一下LeftFragment中的程式碼,如下所示:

package com.zhouzhou.fragmenttest;

import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;

public class LeftFragment extends Fragment {
    @Nullable
    @Override
    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
        View view = inflater.inflate(R.layout.left_fragment,container,false);
        return view;
    }
}

這裡僅僅是重寫了Fragment的onCreateView()方法,然後在這個方法中通過LayoutInflater的inflate()方法將剛才定義的left_fragment佈局動態載入進來,整個方法簡單明瞭。接著我們用同樣的方法再新建一個RightFragment,程式碼如下所示:

package com.zhouzhou.fragmenttest;

import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;

public class RightFragment extends Fragment {
    @Nullable
    @Override
    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
        View view = inflater.inflate(R.layout.right_fragment,container,false);
        return view;
    }
}

接下來修改activity_main.xml中的程式碼,如下所示:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="horizontal"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <fragment
        android:id="@+id/left_fragment"
        android:name="com.zhouzhou.fragmenttest.LeftFragment"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="1"/>
    <fragment
        android:id="@+id/right_fragment"
        android:name="com.zhouzhou.fragmenttest.RightFragment"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="1"/>
</LinearLayout>

用了<fragment>標籤在佈局中新增碎片,其中還需要通過android:name屬性來顯式指明要新增的碎片類名,注意一定要將類的包名也加上。這樣最簡單的碎片示例就已經寫好了,現在執行一下程式,效果如圖:

兩個碎片平分了整個活動的佈局。不過這個例子實在是太簡單了,在真正的專案中很難有什麼實際的作用。

4.2.2 動態新增碎片

在上一節當中,你已經學會了在佈局檔案中新增碎片的方法,不過碎片真正的強大之處在於,它可以在程式執行時動態地新增到活動當中。根據具體情況來動態地新增碎片,你就可以將程式介面定製得更加多樣化。還是在上一節程式碼的基礎上繼續完善,新建another_right_fragment.xml,程式碼如下所示:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:background="#ffff00"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:textSize="20sp"
        android:text="Another right fragment"/>
</LinearLayout>

這個佈局檔案的程式碼和right_fragment.xml中的程式碼基本相同,只是將背景色改成了黃色,並將顯示的文字改了改。然後新建AnotherRightFragment作為另一個右側碎片,程式碼如下所示:

package com.zhouzhou.fragmenttest;

import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;

public class AnotherRightFragment extends Fragment {
    @Nullable
    @Override
    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
        View view = inflater.inflate(R.layout.another_right_fragment,container,false);
        return view;
    }
}

程式碼同樣非常簡單,在onCreateView()方法中載入了剛剛建立的another_right_fragment佈局。這樣我們就準備好了另一個碎片,接下來看一下如何將它動態地新增到活動當中。修改activity_main.xml,程式碼如下所示:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="horizontal"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <fragment
        android:id="@+id/left_fragment"
        android:name="com.zhouzhou.fragmenttest.LeftFragment"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="1"/>
    <FrameLayout
        android:id="@+id/right_layout"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="1"/>
</LinearLayout>

可以看到,現在將右側碎片替換成了一個FrameLayout中,在上一章中學過,這是Android中最簡單的一種佈局,所有的控制元件預設都會擺放在佈局的左上角。由於這裡僅需要在佈局裡放入一個碎片,不需要任何定位,因此非常適合使用FrameLayout。

下面在程式碼中向FrameLayout裡新增內容,從而實現動態新增碎片的功能。修改MainActivity中的程式碼,如下所示:

package com.zhouzhou.fragmenttest;

import androidx.appcompat.app.AppCompatActivity;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentTransaction;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;

public class MainActivity extends AppCompatActivity implements View.OnClickListener {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        Button button = (Button) findViewById(R.id.button);
        button.setOnClickListener(this);
        replaceFragment(new RightFragment());
    }
    @Override
    public void onClick(View view) {
        switch (view.getId()){
                case R.id.button:
                    replaceFragment(new AnotherRightFragment());
                break;
            default:
                break;
        }
    }
    private void replaceFragment(Fragment fragment) {
        FragmentManager fragmentManager = getSupportFragmentManager();
        FragmentTransaction transaction = fragmentManager.beginTransaction();
        transaction.replace(R.id.right_layout,fragment);
        transaction.commit();
    }
}

首先給左側碎片中的按鈕註冊了一個點選事件,然後呼叫replaceFragment()方法動態添加了RightFragment這個碎片。當點選左側碎片中的按鈕時,又會呼叫replaceFragment()方法將右側碎片替換成AnotherRightFragment。

結合replaceFragment()方法中的程式碼可以看出,動態新增碎片主要分為5步。

  1. 建立待新增的碎片例項。
  2. 獲取FragmentManager,在活動中可以直接通過呼叫getSupportFragmentManager()方法得到。
  3. 開啟一個事務,通過呼叫beginTransaction()方法開啟。
  4. 向容器內新增或替換碎片,一般使用replace()方法實現,需要傳入容器的id和待新增的碎片例項。
  5. 提交事務,呼叫commit()方法來完成。

這樣就完成了在活動中動態新增碎片的功能,重新執行程式,可以看到和之前相同的介面,然後點選一下按鈕,效果如圖:

4.2.3 在碎片中模擬返回棧

成功實現了向活動中動態新增碎片的功能,不過嘗試一下就會發現,通過點選按鈕添加了一個碎片之後,這時按下Back鍵程式就會直接退出。

如果這裡想模仿類似於返回棧的效果,按下Back鍵可以回到上一個碎片,該如何實現呢?

其實很簡單,FragmentTransaction中提供了一個addToBackStack()方法,可以用於將一個事務新增到返回棧中,修改MainActivity中的程式碼,如下所:

public class MainActivity extends AppCompatActivity implements View.OnClickListener {

    ...
    private void replaceFragment(Fragment fragment) {
        FragmentManager fragmentManager = getSupportFragmentManager();
        FragmentTransaction transaction = fragmentManager.beginTransaction();
        transaction.replace(R.id.right_layout,fragment);
        //在事務提交之前呼叫了FragmentTransaction的addToBackStack()方法,它可以接收一個名字用於描述返回棧的狀態,一般傳入null即可。
        transaction.addToBackStack(null);
        transaction.commit();
    }
}

現在重新執行程式,並點選按鈕將AnotherRightFragment新增到活動中,然後按下Back鍵,會發現程式並沒有退出,而是回到了RightFragment介面,繼續按下Back鍵,RightFragment介面也會消失,再次按下Back鍵,程式才會退出。

4.2.4 碎片和活動之間進行通訊

雖然碎片都是嵌入在活動中顯示的,可是實際上它們的關係並沒有那麼親密。

碎片和活動都是各自存在於一個獨立的類當中的,它們之間並沒有那麼明顯的方式來直接進行通訊。如果想要在活動中呼叫碎片裡的方法,或者在碎片中呼叫活動裡的方法,應該如何實現呢?

為了方便碎片和活動之間進行通訊,FragmentManager提供了一個類似於findViewById()的方法,專門用於從佈局檔案中獲取碎片的例項,程式碼如下所示:

RightFragment rightFragment = (RightFragment) getSupportFragmentManager().findFragmentById(R.id.right_fragment);

呼叫FragmentManager的findFragmentById()方法,可以在活動中得到相應碎片的例項,然後就能輕鬆地呼叫碎片裡的方法了。

掌握瞭如何在活動中呼叫碎片裡的方法,那在碎片中又該怎樣呼叫活動裡的方法呢?其實這就更簡單了,在每個碎片中都可以通過呼叫getActivity()方法來得到和當前碎片相關聯的活動例項,程式碼如下所示:

MainActivity activity = (MainActivity) getActivity();

有了活動例項之後,在碎片中呼叫活動裡的方法就變得輕而易舉了。另外當碎片中需要使用Context物件時,也可以使用getActivity()方法,因為獲取到的活動本身就是一個Context物件。

這時不知道你心中會不會產生一個疑問:既然碎片和活動之間的通訊問題已經解決了,那麼碎片和碎片之間可不可以進行通訊呢?

這個問題並沒有看上去那麼複雜,它的基本思路非常簡單,首先在一個碎片中可以得到與它相關聯的活動,然後再通過這個活動去獲取另外一個碎片的例項,這樣也就實現了不同碎片之間的通訊功能,因此這裡我們的答案是肯定的。

4.3 碎片的生命週期

和活動一樣,碎片也有自己的生命週期,並且它和活動的生命週期實在是太像了。

4.3.1 碎片的狀態和回撥

  1. 執行狀態

當一個碎片是可見的,並且它所關聯的活動正處於執行狀態時,該碎片也處於執行狀態。

  1. 暫停狀態

當一個活動進入暫停狀態時(由於另一個未佔滿螢幕的活動被新增到了棧頂),與它相關聯的可見碎片就會進入到暫停狀態。

  1. 停止狀態

當一個活動進入停止狀態時,與它相關聯的碎片就會進入到停止狀態,或者通過呼叫FragmentTransaction的remove()、replace()方法將碎片從活動中移除,但如果在事務提交之前呼叫addToBackStack()方法,這時的碎片也會進入到停止狀態。總的來說,進入停止狀態的碎片對使用者來說是完全不可見的,有可能會被系統回收。

  1. 銷燬狀態

碎片總是依附於活動而存在的,因此當活動被銷燬時,與它相關聯的碎片就會進入到銷燬狀態。或者通過呼叫FragmentTransaction的remove()、replace()方法將碎片從活動中移除,但在事務提交之前並沒有呼叫addToBackStack()方法,這時的碎片也會進入到銷燬狀態。

同樣地,Fragment類中也提供了一系列的回撥方法,以覆蓋碎片生命週期的每個環節。其中,活動中有的回撥方法,碎片中幾乎都有,不過碎片還提供了一些附加的回撥方法,那我們就重點看一下這幾個回撥。

  • onAttach()。當碎片和活動建立關聯的時候呼叫。
  • onCreateView()。為碎片建立檢視(載入佈局)時呼叫。
  • onActivityCreated()。確保與碎片相關聯的活動一定已經建立完畢的時候呼叫。
  • onDestroyView()。當與碎片關聯的檢視被移除的時候呼叫。
  • onDetach()。當碎片和活動解除關聯的時候呼叫。

碎片完整的生命週期示意圖可參考圖,圖片源自Android官網。

4.3.2 體驗碎片的生命週期

為了能夠更加直觀地體驗碎片的生命週期,還是通過一個例子來實踐一下。例子很簡單,仍然是在FragmentTest專案的基礎上改動的。修改RightFragment中的程式碼,如下所示:

package com.zhouzhou.fragmenttest;

import android.content.Context;
import android.os.Bundle;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;

public class RightFragment extends Fragment {
    public static final String TAG = "RightFragment";

    @Override
    public void onAttach(@NonNull Context context) {
        super.onAttach(context);
        Log.d(TAG,"onAttach");
    }

    @Override
    public void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        Log.d(TAG,"onCreate");
    }
    @Nullable
    @Override
    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
        Log.d(TAG,"onCreateView");
        View view = inflater.inflate(R.layout.right_fragment,container,false);
        return view;
    }
    @Override
    //被棄用
    public void onActivityCreated(@Nullable Bundle savedInstanceState) {
        super.onActivityCreated(savedInstanceState);
        Log.d(TAG,"onActivityCreated");
    }
    @Override
    public void onStart() {
        super.onStart();
        Log.d(TAG,"onStart");
    }
    @Override
    public void onResume() {
        super.onResume();
        Log.d(TAG, "onResume");
    }
    @Override
    public void onPause() {
        super.onPause();
        Log.d(TAG,"onPause");
    }
    @Override
    public void onStop() {
        super.onStop();
        Log.d(TAG,"onStop");
    }
    @Override
    public void onDestroyView() {
        super.onDestroyView();
        Log.d(TAG,"onDestroyView");
    }
    @Override
    public void onDestroy() {
        super.onDestroy();
        Log.d(TAG,"onDestroy");
    }
    @Override
    public void onDetach() {
        super.onDetach();
        Log.d(TAG,"onDetach");
    }
}

在RightFragment中的每一個回撥方法裡都加入了列印日誌的程式碼,然後重新執行程式,這時觀察logcat中的列印資訊,如圖:

可以看到,當RightFragment第一次被載入到螢幕上時,會依次執行onAttach()、onCreate()、onCreateView()、onActivityCreated()、onStart()和onResume()方法。然後點選LeftFragment中的按鈕,此時列印資訊如圖:

由於AnotherRightFragment替換了RightFragment,此時的RightFragment進入了停止狀態,因此onPause()、onStop()和onDestroyView()方法會得到執行。

當然如果在替換的時候沒有呼叫addToBackStack()方法,此時的RightFragment就會進入銷燬狀態,onDestroy()和onDetach()方法就會得到執行。接著按下Back鍵,RightFragment會重新回到螢幕,列印資訊如圖:

由於RightFragment重新回到了執行狀態,因此onCreateView()、onActivityCreated()、onStart()和onResume()方法會得到執行。注意此時onCreate()方法並不會執行,因為我們藉助了addToBackStack()方法使得RightFragment並沒有被銷燬。現在再次按下Back鍵,列印資訊如圖:

依次會執行onPause()、onStop()、onDestroyView()、onDestroy()和onDetach()方法,最終將碎片銷燬掉。這樣碎片完整的生命週期體驗了一遍。另外值得一提的是,在碎片中你也是可以通過onSaveInstanceState()方法來儲存資料的,因為進入停止狀態的碎片有可能在系統記憶體不足的時候被回收。儲存下來的資料在onCreate()、onCreateView()和onActivityCreated()這3個方法中你都可以重新得到,它們都含有一個Bundle型別的savedInstanceState引數。

4.4 動態載入佈局的技巧

雖然動態新增碎片的功能很強大,可以解決很多實際開發中的問題,但是它畢竟只是在一個佈局檔案中進行一些新增和替換操作。

如果程式能夠根據裝置的解析度或螢幕大小在執行時來決定載入哪個佈局,那我們可發揮的空間就更多了。因此本節我們就來探討一下Android中動態載入佈局的技巧。

4.4.1 使用限定符

現在很多的平板應用都採用的是雙頁模式(程式會在左側的面板上顯示一個包含子項的列表,在右側的面板上顯示內容),因為平板電腦的螢幕足夠大,完全可以同時顯示下兩頁的內容,但手機的螢幕一次就只能顯示一頁的內容,因此兩個頁面需要分開顯示。

那麼怎樣才能在執行時判斷程式應該是使用雙頁模式還是單頁模式呢?這就需要藉助限定符(Qualifiers)來實現了。下面我們通過一個例子來學習一下它的用法,修改FragmentTest專案中的activity_main.xml檔案,程式碼如下所示:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="horizontal"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <fragment
        android:id="@+id/left_fragment"
        android:name="com.zhouzhou.fragmenttest.LeftFragment"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>
</LinearLayout>

將多餘的程式碼都刪掉,只留下一個左側碎片,並讓它充滿整個父佈局。接著在res目錄下新建layout-large資料夾,在這個資料夾下新建一個佈局,也叫作activity_main.xml,程式碼如下所示:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="horizontal"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <fragment
        android:id="@+id/left_fragment"
        android:name="com.zhouzhou.fragmenttest.LeftFragment"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="1"/>
    <fragment
        android:id="@+id/right_fragment"
        android:name="com.zhouzhou.fragmenttest.RightFragment"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="3"/>
</LinearLayout>

可以看到,layout/activity_main佈局只包含了一個碎片,即單頁模式,而layout-large/activity_main佈局包含了兩個碎片,即雙頁模式。其中large就是一個限定符,那些螢幕被認為是large的裝置就會自動載入layout-large資料夾下的佈局,而小螢幕的裝置則還是會載入layout資料夾下的佈局。

然後將MainActivity中replaceFragment()方法裡的程式碼註釋掉,並在平板模擬器上重新執行程式,效果如圖:

再啟動一個手機模擬器,並在這個模擬器上重新執行程式,效果如圖:

這樣我們就實現了在程式執行時動態載入佈局的功能。Android中一些常見的限定符可以參考下表。

4.4.2 使用最小寬度限定符

使用large限定符成功解決了單頁雙頁的判斷問題,不過很快又有一個新的問題出現了,large到底是指多大呢?有的時候我們希望可以更加靈活地為不同裝置載入佈局,不管它們是不是被系統認定為large,這時就可以使用最小寬度限定符(Smallest-width Qualifier)了。最小寬度限定符允許我們對螢幕的寬度指定一個最小值(以dp為單位),然後以這個最小值為臨界點,螢幕寬度大於這個值的裝置就載入一個佈局,螢幕寬度小於這個值的裝置就載入另一個佈局。在res目錄下新建layout-sw600dp資料夾,然後在這個資料夾下新建activity_main.xml佈局,程式碼如下所示:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="horizontal"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <fragment
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="1"
        android:id="@+id/left_fragment"
        android:name="com.zhouzhou.fragmenttest.LeftFragment"/>
    <fragment
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="3"
        android:id="@+id/right_fragment"
        android:name="com.zhouzhou.fragmenttest.RightFragment"/>
</LinearLayout>

這就意味著,當程式執行在螢幕寬度大於等於600dp的裝置上時,會載入layout-sw600dp/activity_main佈局,當程式執行在螢幕寬度小於600dp的裝置上時,則仍然載入預設的layout/activity_main佈局。

4.5 碎片的最佳實踐——一個簡易版的新聞應用

前面有提到過,碎片很多時候都是在平板開發當中使用的,主要是為了解決螢幕空間不能充分利用的問題。那是不是就表明,我們開發的程式都需要提供一個手機版和一個Pad版呢?

確實有不少公司都是這麼做的,但是這樣會浪費很多的人力物力。因為維護兩個版本的程式碼成本很高,每當增加什麼新功能時,需要在兩份程式碼裡各寫一遍,每當發現一個bug時,需要在兩份程式碼裡各修改一次。因此今天我們最佳實踐的內容就是,教你如何編寫同時相容手機和平板的應用程式。

現在就將運用本章中所學的知識來編寫一個簡易版的新聞應用,並且要求它是可以同時相容手機和平板的。

新建好一個FragmentBestPractice專案,並且會使用到RecyclerView。要準備好一個新聞的實體類,新建類News,程式碼如下所示:

package com.zhouzhou.fragmentbestpractice;

public class News {
    private String title;
    private String content;

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public String getContent() {
        return content;
    }

    public void setContent(String content) {
        this.content = content;
    }
}

News類的程式碼還是比較簡單的,title欄位表示新聞標題,content欄位表示新聞內容。接著新建佈局檔案news_content_frag.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">
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:id="@+id/visibility_layout"
        android:orientation="vertical"
        android:visibility="invisible">
        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:id="@+id/news_title"
            android:gravity="center"
            android:padding="10dp"
            android:textSize="20sp"/>
        <View
            android:layout_width="match_parent"
            android:layout_height="1dp"
            android:background="#000"/>
        <TextView
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:id="@+id/news_content"
            android:layout_weight="1"
            android:padding="15dp"
            android:textSize="18sp"/>
    </LinearLayout>
    <View
        android:layout_width="1dp"
        android:layout_height="match_parent"
        android:layout_alignParentLeft="true"
        android:background="#000"/>
</RelativeLayout>

新聞內容的佈局主要可以分為兩個部分,頭部部分顯示新聞標題,正文部分顯示新聞內容,中間使用一條細線分隔開。這裡的細線是利用View來實現的,將View的寬或高設定為1dp,再通過background屬性給細線設定一下顏色就可以了。這裡我們把細線設定成黑色。

然後再新建一個NewsContentFragment類,繼承自Fragment,程式碼如下所示:

package com.zhouzhou.fragmentbestpractice;

import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;

public class NewsContentFragment extends Fragment {
    private View view;

    @Nullable
    @Override
    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
        view = inflater.inflate(R.layout.news_content_frag,container,false);
        return view;
    }
    public void refresh(String newsTitle,String newsContent){
        View visibilityLayout = view.findViewById(R.id.visibility_layout);
        visibilityLayout.setVisibility(View.VISIBLE);
        TextView newsTitleText = (TextView) view.findViewById(R.id.news_title);
        TextView newsContentText = (TextView) view.findViewById(R.id.news_content); 
        newsTitleText.setText(newsTitle);//重新整理新聞的標題
        newsContentText.setText(newsContent);//重新整理新聞內容
    }
}
  • 首先在onCreateView()方法里加載了我們剛剛建立的news_content_frag佈局。
  • 又提供了一個refresh()方法,這個方法就是用於將新聞的標題和內容顯示在介面上的。這裡通過findViewById()方法分別獲取到新聞標題和內容的控制元件,然後將方法傳遞進來的引數設定進去。

這樣我們就把新聞內容的碎片和佈局都建立好了,但是它們都是在雙頁模式中使用的,如果想在單頁模式中使用的話,還需要再建立一個活動。右擊com.zhouzhou.fragmentbestpractice包→New→Activity→EmptyActivity,新建一個NewsContentActivity,並將佈局名指定成news_content,然後修改news_content.xml中的程式碼,如下所示:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <fragment
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:id="@+id/news_content_fragment"
        android:name="com.zhouzhou.fragmentbestpractice.NewsContentFragment"/>
</LinearLayout>

這裡我們充分發揮了程式碼的複用性,直接在佈局中引入了NewsContentFragment,這樣也就相當於把news_content_frag佈局的內容自動加了進來。然後修改NewsContentActivity中的程式碼,如下所示:

package com.zhouzhou.fragmentbestpractice;

import androidx.appcompat.app.AppCompatActivity;

import android.content.Context;
import android.content.Intent;
import android.os.Bundle;

public class NewsContentActivity extends AppCompatActivity {
    public static void actionStart(Context context,String newsTitle,String newsContent){
        Intent intent = new Intent(context,NewsContentActivity.class);
        intent.putExtra("news_title",newsTitle);
        intent.putExtra("news_content",newsContent);
        context.startActivity(intent);
    }
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.news_content);
        String newsTitle = getIntent().getStringExtra("news_title");//獲取傳入的新聞標題
        String newsContent = getIntent().getStringExtra("news_content");//獲取傳入的新聞的內容
        NewsContentFragment newsContentFragment = (NewsContentFragment) getSupportFragmentManager().findFragmentById(R.id.news_content_fragment);
        newsContentFragment.refresh(newsTitle,newsContent);//重新整理NewsContentFragment介面
    }
}

可以看到,在onCreate()方法中通過Intent獲取到了傳入的新聞標題和新聞內容,然後呼叫FragmentManager的findFragmentById()方法得到了NewsContentFragment的例項,接著呼叫它的refresh()方法,並將新聞的標題和內容傳入,就可以把這些資料顯示出來了。

注意這裡我們還提供了一個actionStart()方法,如果忘記的話就再去閱讀一遍2.6.3小節吧。接下來還需要再建立一個用於顯示新聞列表的佈局,新建news_title_frag.xml,程式碼如下所示:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.recyclerview.widget.RecyclerView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:id="@+id/news_title_recycler_view"/>
</LinearLayout>

這個佈局裡面只有一個用於顯示新聞列表的RecyclerView。既然要用到RecyclerView,那麼就必定少不了子項的佈局。新建news_item.xml作為RecyclerView子項的佈局,程式碼如下所示:

<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:id="@+id/news_title"
        android:maxLines="1"
        android:ellipsize="end"
        android:textSize="18sp"
        android:paddingLeft="10dp"
        android:paddingRight="10dp"
        android:paddingTop="15dp"
        android:paddingBottom="15dp"/>

子項的佈局也非常簡單,只有一個TextView。

  • android:padding表示給控制元件的周圍加上補白,這樣不至於讓文字內容會緊靠在邊緣上。
  • android:maxLines設定為1表示讓這個TextView只能單行顯示。
  • android:ellipsize用於設定當文字內容超出控制元件寬度時,文字的縮略方式,end表示在尾部進行縮略。

既然新聞列表和子項的佈局都已經建立好了,那麼接下來我們就需要一個用於展示新聞列表的地方。這裡新建NewsTitleFragment作為展示新聞列表的碎片,程式碼如下所示:

package com.zhouzhou.fragmentbestpractice;

import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;

public class NewsTitleFragment extends Fragment {
    private boolean isTwoPane;

    @Nullable
    @Override
    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
        View view = inflater.inflate(R.layout.news_title_frag,container,false);
        return view;
    }
    @Override
    public void onActivityCreated(@Nullable Bundle savedInstanceState) {
        super.onActivityCreated(savedInstanceState);
        if (getActivity().findViewById(R.id.news_content_layout)!= null){
            isTwoPane = true;//可以找到news_content_layout佈局時,為雙頁模式
        }else {
            isTwoPane = false;//找不到news_content_layout佈局時,為單頁模式
        }
    }
}

NewsTitleFragment中並沒有多少程式碼,在onCreateView()方法中載入了news_title_frag佈局。

注意看一下onActivityCreated()方法,這個方法通過在活動中能否找到一個id為news_content_layout的View來判斷當前是雙頁模式還是單頁模式,因此我們需要讓這個id為news_content_layout的View只在雙頁模式中才會出現。那麼怎樣才能實現這個功能呢?其實並不複雜,只需要藉助我們剛剛學過的限定符就可以了。

首先修改activity_main.xml中的程式碼,如下所示:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/news_title_layout"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <fragment
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:id="@+id/news_title_fragment"
        android:name="com.zhouzhou.fragmentbestpractice.NewsTitleFragment"/>
</FrameLayout>

上述程式碼表示,在單頁模式下,只會載入一個新聞標題的碎片。然後新建layout-sw600dp資料夾,在這個資料夾下再新建一個activity_main.xml檔案,程式碼如下所示:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="horizontal"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <fragment
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="1"
        android:id="@+id/news_title_fragment"
        android:name="com.zhouzhou.fragmentbestpractice.NewsTitleFragment"/>
    <FrameLayout
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="3"
        android:id="@+id/news_content_layout"/>
    <fragment
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:id="@+id/news_content_fragment"
        android:name="com.zhouzhou.fragmentbestpractice.NewsContentFragment"/>
</LinearLayout>

可以看出,在雙頁模式下我們同時引入了兩個碎片,並將新聞內容的碎片放在了一個FrameLayout佈局下,而這個佈局的id正是news_content_layout。因此,能夠找到這個id的時候就是雙頁模式,否則就是單面模式。

現在還剩下至關重要的一點,就是在NewsTitleFragment中通過RecyclerView將新聞列表展示出來。在NewsTitleFragment中新建一個內部類NewsAdapter來作為RecyclerView的介面卡,如下所示:

package com.zhouzhou.fragmentbestpractice;

import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.recyclerview.widget.RecyclerView;

import java.util.List;

public class NewsTitleFragment extends Fragment {
    private boolean isTwoPane;

    @Nullable
    @Override
    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
        View view = inflater.inflate(R.layout.news_title_frag,container,false);
        return view;
    }
    @Override
    public void onActivityCreated(@Nullable Bundle savedInstanceState) {
        super.onActivityCreated(savedInstanceState);
        if (getActivity().findViewById(R.id.news_content_layout)!= null){
            isTwoPane = true;//可以找到news_content_layout佈局時,為雙頁模式
        }else {
            isTwoPane = false;//找不到news_content_layout佈局時,為單頁模式
        }
    }
    class NewsAdapter extends RecyclerView.Adapter<NewsAdapter.ViewHolder>{
        private List<News> mNewsList;

        @NonNull
        @Override
        public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
            View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.news_item,parent,false);
            final ViewHolder holder = new ViewHolder(view);
            view.setOnClickListener(new View.OnClickListener() {
                @Override
                public void onClick(View view) {
                    News news = mNewsList.get(holder.getAdapterPosition());
                    if(isTwoPane){
                        //如果時雙頁模式,重新整理NewsContentFragment中的內容
                        NewsContentFragment newsContentFragment = (NewsContentFragment) getFragmentManager().findFragmentById(R.id.news_content_fragment);
                        newsContentFragment.refresh(news.getTitle(),news.getContent());
                    }else{
                        //如果是單頁模式,則直接啟動NewsContentActivity
                        NewsContentActivity.actionStart(getActivity(),news.getTitle(),news.getContent());
                    }
                }
            });
            return holder;
        }
        @Override
        public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
            News news = mNewsList.get(position);
            holder.newsTitleText.setText(news.getTitle());
        }

        @Override
        public int getItemCount() {
            return mNewsList.size();
        }
        class ViewHolder extends RecyclerView.ViewHolder{
            TextView newsTitleText;
            public ViewHolder(View view){
                super(view);
                newsTitleText = (TextView) view.findViewById(R.id.news_title);
            }
        }
        public NewsAdapter(List<News>newsList){
            mNewsList = newsList;
        }
    }
}

需要注意的是,之前我們都是將介面卡寫成一個獨立的類,其實也是可以寫成內部類的,這裡寫成內部類的好處就是可以直接訪問NewsTitleFragment的變數,比如isTwoPane。觀察一下onCreateViewHolder()方法中註冊的點選事件,首先獲取到了點選項的News例項,然後通過isTwoPane變數來判斷當前是單頁還是雙頁模式,如果是單頁模式,就啟動一個新的活動去顯示新聞內容,如果是雙頁模式,就更新新聞內容碎片裡的資料。

最後就是向RecyclerView中填充資料了。修改NewsTitleFragment中的程式碼,如下所示:

package com.zhouzhou.fragmentbestpractice;

import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;

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

public class NewsTitleFragment extends Fragment {
    private boolean isTwoPane;

    @Nullable
    @Override
    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
        View view = inflater.inflate(R.layout.news_title_frag,container,false);
        RecyclerView newsTitleRecyclerView = (RecyclerView) view.findViewById(R.id.news_title_recycler_view);
        LinearLayoutManager layoutManager = new LinearLayoutManager(getActivity());
        newsTitleRecyclerView.setLayoutManager(layoutManager);
        NewsAdapter adapter = new NewsAdapter(getNews());
        newsTitleRecyclerView.setAdapter(adapter);
        return view;
    }
    private List<News> getNews(){
        List<News> newsList = new ArrayList<>();
        for (int i = 0; i <=50 ; i++) {
            News news = new News();
            news.setTitle("This is news title " + i);
            news.setContent(getRandomLengthContent("News Content" + i + "."));
            newsList.add(news);
        }
        return newsList;
    }
    private String getRandomLengthContent(String content){
        Random random = new Random();
        int length = random.nextInt(20) + 1;
        StringBuilder builder = new StringBuilder();
        for (int i = 0; i < length; i++) {
            builder.append(content);
        }
        return builder.toString();
    }
    @Override
    public void onActivityCreated(@Nullable Bundle savedInstanceState) {
        super.onActivityCreated(savedInstanceState);
        if (getActivity().findViewById(R.id.news_content_layout)!= null){
            isTwoPane = true;//可以找到news_content_layout佈局時,為雙頁模式
        }else {
            isTwoPane = false;//找不到news_content_layout佈局時,為單頁模式
        }
    }
    class NewsAdapter extends RecyclerView.Adapter<NewsAdapter.ViewHolder>{
        private List<News> mNewsList;

        @NonNull
        @Override
        public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
            View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.news_item,parent,false);
            final ViewHolder holder = new ViewHolder(view);
            view.setOnClickListener(new View.OnClickListener() {
                @Override
                public void onClick(View view) {
                    News news = mNewsList.get(holder.getAdapterPosition());
                    if(isTwoPane){
                        //如果時雙頁模式,重新整理NewsContentFragment中的內容
                        NewsContentFragment newsContentFragment = (NewsContentFragment) getFragmentManager().findFragmentById(R.id.news_content_fragment);
                        newsContentFragment.refresh(news.getTitle(),news.getContent());
                    }else{
                        //如果是單頁模式,則直接啟動NewsContentActivity
                        NewsContentActivity.actionStart(getActivity(),news.getTitle(),news.getContent());
                    }
                }
            });
            return holder;
        }
        @Override
        public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
            News news = mNewsList.get(position);
            holder.newsTitleText.setText(news.getTitle());
        }

        @Override
        public int getItemCount() {
            return mNewsList.size();
        }
        class ViewHolder extends RecyclerView.ViewHolder{
            TextView newsTitleText;
            public ViewHolder(View view){
                super(view);
                newsTitleText = (TextView) view.findViewById(R.id.news_title);
            }
        }
        public NewsAdapter(List<News>newsList){
            mNewsList = newsList;
        }
    }
}

可以看到,onCreateView()方法中添加了RecyclerView標準的使用方法,在碎片中使用RecyclerView和在活動中使用幾乎是一模一樣的。另外,這裡呼叫了getNews()方法來初始化50條模擬新聞資料,同樣使用了一個getRandomLengthContent()方法來隨機生成新聞內容的長度,以保證每條新聞的內容差距比較大。

這樣所有的編寫工作就已經完成了,執行一下吧!首先在手機模擬器上執行,效果如圖:

可以看到許多條新聞的標題,然後點選第一條新聞,會啟動一個新的活動來顯示新聞的內容,效果如圖:

接下來將程式在平板模擬器上執行,同樣點選第一條新聞,效果如圖:

到這裡為止,已經基本將Android UI相關的重要知識點都學完了。後面在很長一段時間內都不會再系統性地介紹UI方面的知識,而是將結合前面所學的UI知識來更好地講解相應章節的內容。