Jetpack之Navigation全面剖析

Navigaion 是Android JetPack框架中的一員,是一套新的Fragment管理框架,可以幫助開發者很好的處理fragment之間的跳轉,優雅的支持fragment之間的轉場動畫,支持通過deeplink直接定位到fragment. 通過第三方的插件支持fragment之間安全的參數傳遞,可以可視化的編輯各個組件之間的跳轉關系。導航組件的推出,使得我們在搭架應用架構的時候,可以考慮一個功能模塊就是一個Activity, 模塊中每個子頁面使用Fragment實現,使用Navigation處理Fragment之間的導航。更有甚者,設計一個單Activity的應用也不是沒有可能。最后還要提一點,Navigation不只是能管理Fragment,它還支持Activity,小伙伴們請注意這一點。

下面我們來詳細介紹下Navigation的使用,在使用之前我們來先了解3個核心概念:
1、Navigation Graph 這是Navigation的配置文件,位于res/navigation/目錄下的xml文件. 這個文件是對導航中各個組件的跳轉關系的預覽。在design模式下,可以很清晰的看到組件之間關系,如圖1所示。
2、NavHost 一個空白的父容器,承擔展示目的fragment的作用。源碼中父容器的實現是NavHostFragment,在Activity中引入這個fragment才能使用Navigation的能力。
3、NavController 導航組件的跳轉控制器,管理導航的對象,控制NavHost中目標頁面的展示。

下面我們從一個簡單的例子先看下Navigation的基本用法。

一 工程搭建

我們設計一個應用,分別實現首頁,詳情頁,購買頁,登錄頁,注冊頁。跳轉關系如下:
首頁->詳情頁->購買頁->首頁,首頁->登錄頁->注冊頁->首頁。如果使用FragmentManager管理,需要對頁面創建,參數傳遞以及頁面回退做許多工作,下面我們看一下Navigation是如何管理這些頁面的。
首先,創建一個空白的工程.只包含一個activity. 修改工程的build.gradle文件使之包含下面的引用


def nav_version ="2.3.0"

// Java language implementation

implementation"androidx.navigation:navigation-fragment:$nav_version"

implementation"androidx.navigation:navigation-ui:$nav_version"

// Kotlin

implementation"androidx.navigation:navigation-fragment-ktx:$nav_version"

implementation"androidx.navigation:navigation-ui-ktx:$nav_version"

// Dynamic Feature Module Support

implementation"androidx.navigation:navigation-dynamic-features-fragment:$nav_version"

// Testing Navigation

androidTestImplementation"androidx.navigation:navigation-testing:$nav_version"

在“Project”窗口中,右鍵點擊 res 目錄,然后依次選擇 New > Android Resource File,此時系統會顯示 New Resource File 對話框。在 File name 字段中輸入名稱,例如“nav_graph”。從 Resource type 下拉列表中選擇 Navigation,然后點擊 OK,生成的導航的xml (圖1中1位置)。

圖1

在可視化編輯模式下,點擊左上角的 icon(圖1中2位置)在xml中添加導航頁面. 添加完導航頁面,選中一個頁面,在右側的屬性欄,可以為頁面添加跳轉action, deeplink和跳轉傳參。直接把兩個頁面之間連線,也可以建立跳轉的action. 選中一條頁面間的連線,可以編輯這個action,為action添加轉場動畫,出棧屬性和傳參默認值。
右鍵點擊一個頁面,在右鍵菜單中選擇edit, 就可以編輯對應fragment的xml文件.
都配置完成后,最終的導航圖就如圖2所示。
建立完導航圖,我們還需要設置一個當做首頁的Fragment一啟動就展示,在要設置的Fragment上點擊右鍵,選擇Set Start Destination,將它設置為首頁,設置完成后,被選中的Fragment會有一個start標簽(圖1中3位置)當Activity啟動的時候,它會做為默認的頁面替換布局中的NavHostFragment。

下面是nav_graph.xml配置文件部分內容,xml文件如下

<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/nav_graph"
    app:startDestination="@id/homeFragment">
    <fragment
        android:id="@+id/homeFragment"
        android:name="com.example.navicasetest.HomeFragment"
        android:label="fragment_home"
        tools:layout="@layout/fragment_home" >
        <action
            android:id="@+id/action_homeFragment_to_detailFragment"
            app:destination="@id/detailFragment"
            app:enterAnim="@anim/slide_in_right"
            app:exitAnim="@anim/slide_out_left"
            app:popEnterAnim="@anim/slide_in_left"
            app:popExitAnim="@anim/slide_out_right" />
        <action
            android:id="@+id/action_homeFragment_to_loginFragment"
            app:destination="@id/loginFragment" />
    </fragment>
    <!--這里省略其他的fragment的配置-->
    ...
</navigation>

通過上面的配置,我們就完整的創建了一個導航圖。如下圖所示


圖2

下面就需要把導航添加到activity中。
在MainActivity的xml中,添加Navigation的容器 NavHostFragment, NavHostFragment是系統類,我們后面分析它內部的實現。xml配置如下

<fragment
    android:id="@+id/fragment"
    android:name="androidx.navigation.fragment.NavHostFragment"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:defaultNavHost="true"
    app:navGraph="@navigation/nav_graph"
    />

我們發現xml中有2個新的配置項,app:navGraph指定導航配置文件。app:defaultNavHost 置為true,標識是讓當前的導航容器NavHostFragment處理系統返回鍵,在 Navigation 容器中如果有頁面的跳轉,點擊返回按鈕會先處理 容器中 Fragment 頁面間的返回,處理完容器中的頁面,再處理 Activity 頁面的返回。如果值為 false 則直接處理 Activity 頁面的返回。

二 頁面跳轉和參數傳遞

頁面間的跳轉是通過action來實現,我們在HomeFragment中增加detail button的點擊響應,實現從首頁到詳情頁的跳轉,代碼實現如下。這里用到了NavController,我們后面會詳細介紹它,這里先看它的用法。

mBtnGoDetail.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        NavController contorller = Navigation.findNavController(view);
        contorller.navigate(R.id.action_homeFragment_to_detailFragment);
    }
});

下面介紹如何在導航之間傳遞參數

1、Bundle方式

第一種方式是通過Bundle的方式。NavController 的navigate方法提供了傳入參數是Bundle的方法,下面看一下實例代碼。從首頁傳參到商品詳情頁,首頁傳入參數

Bundle bundle = new Bundle();
bundle.putString("product_name","蘋果");
bundle.putFloat("price",10.5f);
NavController contorller = Navigation.findNavController(view);
contorller.navigate(R.id.action_homeFragment_to_detailFragment, bundle);

解析傳參

if (getArguments() != null) {
    mProductName = getArguments().getString("product_name");
    mPrice = getArguments().getFloat("price");
}

如果兩個fragment直接傳遞的參數較多,這種傳參方法就顯得很不友好,需要定義好多名字,并且不能保證傳參的一致性,還容易出錯或者自定義一個model,實現序列化方法。這樣也是比較繁瑣。

Android 系統還提供一種SafeArg的傳參方式。比較優雅的處理參數的傳遞。

2、安全參數(SafeArg)

第一步,在工程的build.gradle中添加下面的引用

classpath "android.arch.navigation:navigation-safe-args-gradle-plugin:1.0.0"

在app的build.gradle中增加

apply plugin: 'androidx.navigation.safeargs'

第二步,編輯navigation的xml文件 在本例中是nav_graph.xml. 可以通過可視化編輯,也可以直接編輯xml. 編輯完畢如下圖

<fragment
    android:id="@+id/detailFragment"
    android:name="com.example.myapplication.DetailFragment"
    android:label="fragment_detail"
    tools:layout="@layout/fragment_detail" >
    <action
        android:id="@+id/action_detailFragment_to_payFragment"
        app:destination="@id/payFragment" />
    <argument
        android:name="productName"
        app:argType="string"
        android:defaultValue="unknow" />
    <argument
        android:name="price"
        app:argType="float"
        android:defaultValue="0" />
</fragment>

修改完xml后,編譯一下工程,在generate文件夾下會生成幾個文件。如下圖


WechatIMG26.png

在首頁的跳轉函數中,寫下如下代碼

mBtnGoDetailBySafe.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        Bundle bundle = new DetailFragmentArgs.Builder().setProductName("蘋果").setPrice(10.5f).build().toBundle();
        NavController contorller = Navigation.findNavController(view);
        contorller.navigate(R.id.action_homeFragment_to_detailFragment, bundle);
    }
});

在詳情頁接收傳參的地方,解析傳參的代碼

Bundle bundle = getArguments();
if(bundle != null){
    mProductName = DetailFragmentArgs.fromBundle(bundle).getProductName();
    mPrice = DetailFragmentArgs.fromBundle(bundle).getPrice();
}

DetailFragmentArgs內部是使用了builder模式構建傳參的bundle. 并且以getter,setter的方式設置屬性值,這樣開發人員使用起來比較簡潔,和使用普通java bean的方式基本一致。
細心的同學發現了,上面除了DetailFragmentArgs 還生成了2個direction類,我們以HomeFragmentDirections為例看下用法,HomeFragmentDirections能夠直接提供跳轉的OnClickListener,

mBtnGoDetailBySafe.setOnClickListener(Navigation.createNavigateOnClickListener(HomeFragmentDirections.
        actionHomeFragmentToDetailFragment().setProductName("蘋果").setPrice(10.5f)));

分析HomeFragmentDirections代碼不難發現,本質是將action id與argument封裝成一個NavDirections,內部通過解析它來獲取action id與argument,最終還是會執行NavController的navigation方法執行跳轉。下面看一下HomeFragmentDirections的內部實現。

@NonNull
public static ActionHomeFragmentToDetailFragment actionHomeFragmentToDetailFragment(){
    return new ActionHomeFragmentToDetailFragment();
}

public static class ActionHomeFragmentToDetailFragment implements NavDirections {
    private final HashMap arguments = new HashMap();

    private ActionHomeFragmentToDetailFragment() {
    }

    @NonNull
    public ActionHomeFragmentToDetailFragment setProductName(@NonNull String productName) {
        if (productName == null) {
            throw new IllegalArgumentException("Argument \"productName\" is marked as non-null but was passed a null value.");
        }
        this.arguments.put("productName", productName);
        return this;
    }

    @NonNull
    public ActionHomeFragmentToDetailFragment setPrice(float price) {
        this.arguments.put("price", price);
        return this;
    }

    @Override
    public int getActionId() {
        return R.id.action_homeFragment_to_detailFragment;
    }

    @SuppressWarnings("unchecked")
    @NonNull
    public String getProductName() {
        return (String) arguments.get("productName");
    }

    @SuppressWarnings("unchecked")
    public float getPrice() {
        return (float) arguments.get("price");
    }

}
3、ViewModel.

導航架構中,也可以通過ViewModel的方式共享數據,后面我們還會講到使用ViewMode的必要性。每個Destination共享一份ViewModel,這樣有利于及時監聽數據變化,同時把數據展示和存儲隔離。在上面的例子中,每個頁面都需要登錄狀態,我們把用戶登錄狀態封裝成UserViewModel,在需要監聽登錄數據變化的頁面實現如下代碼

userViewModel.getUserModel().observe(getViewLifecycleOwner(), new Observer<UserModel>() {
    @Override
    public void onChanged(UserModel userModel) {
        if(userModel != null){
            //登錄成功,展示用戶名
            mUserName.setText(userModel.getUserName());
        } else {
            mUserName.setText("未登錄");
        }
    }
});

這樣當用戶登錄后,各個頁面都會得到通知,刷新當前的昵稱展示。

三 動畫

多數場景下,2個頁面之間的切換,我們希望有轉場動畫,Navigation對動畫的支持也很簡單。可以在xml中直接配置配置。

<fragment
    android:id="@+id/homeFragment"
    android:name="com.example.navicasetest.HomeFragment"
    android:label="fragment_home"
    tools:layout="@layout/fragment_home" >
    <action
        android:id="@+id/action_homeFragment_to_detailFragment"
        app:destination="@id/detailFragment"
        app:enterAnim="@anim/slide_in_right"
        app:exitAnim="@anim/slide_out_left"
        app:popEnterAnim="@anim/slide_in_left"
        app:popExitAnim="@anim/slide_out_right" />
</fragment>

enterAnim: 配置進場時目標頁面動畫
exitAnim: 配置進場時原頁面動畫
popEnterAnim: 配置回退時目標頁面動畫
popExitAnim: 配置回退時原頁面動畫
配置完后,動畫展示如下


動畫

四 導航堆棧管理

Navigation 有自己的任務棧,每次調用navigate()函數,都是一個入棧操作,出棧操作有以下幾種方式,下面詳細介紹幾種出棧方式和使用場景。

1、系統返回鍵

首先需要在xml中配置app:defaultNavHost="true",才能讓導航容器攔截系統返回鍵,點擊系統返回鍵,是默認的出棧操作,回退到上一個導航頁面。如果當棧中只剩一個頁面的時候,系統返回鍵將由當前Activity處理。

2、自定義返回鍵

如果頁面上有返回按鈕,那么我們可以調用popBackStack()或者navigateUp()返回到上一個頁面。我們先看一下navigateUp源碼

public boolean navigateUp() {
    if (getDestinationCountOnBackStack() == 1) {
        // If there's only one entry, then we've deep linked into a specific destination
        // on another task so we need to find the parent and start our task from there
        NavDestination currentDestination = getCurrentDestination();
        int destId = currentDestination.getId();
        NavGraph parent = currentDestination.getParent();
        while (parent != null) {
            if (parent.getStartDestination() != destId) {
                //省略部分代碼
                return true;
            }
            destId = parent.getId();
            parent = parent.getParent();
        }
        // We're already at the startDestination of the graph so there's no 'Up' to go to
        return false;
    } else {
        return popBackStack();
    }
}

從源碼可以看出,當棧中任務大于1個的時候,兩個函數沒什么區別。當棧中只有一個導航首頁(start destination)的時候,navigateUp()不會彈出導航首頁,它什么都不做,直接返回false.
popBackStack則會把導航首頁也出棧,但是由于沒有回退到任何其他頁面,此時popBackStack會返回false, 如果此時又繼續調用navigate()函數,會發生exception。所以google官網說不建議把導航首頁也出棧。如果導航首頁出棧了,此時需要關閉當前Activity。或者跳轉到其他導航頁面。示例代碼如下。

...

if (!navController.popBackStack()) {
    // Call finish() on your Activity
    finish();
}
3、popUpTo 和 popUpToInclusive

還有一種出棧方式,就是通過設置popUpTo和popUpToInclusive在導航過程中彈出頁面。
popUpTo指出棧直到某目標,字面意思比較難理解,我們看下面這個例子。
假設有A,B,C 3個頁面,跳轉順序是 A to B,B to C,C to A。
依次執行幾次跳轉后,棧中的順序是A>B>C>A>B>C>A。此時如果用戶按返回鍵,會發現反復出現重復的頁面,此時用戶的預期應該是在A頁面點擊返回,應該退出應用。
此時就需要在C到A的action中設置popUpTo="@id/a". 這樣在C跳轉A的過程中會把B,C出棧。但是還會保留上一個A的實例,加上新創建的這個A的實例,就會出現2個A的實例. 此時就需要設置
popUpToInclusive=true. 這個配置會把上一個頁面的實例也彈出棧,只保留新建的實例。
下面再分析一下設置成false的場景。還是上面3個頁面,跳轉順序A to B,B to C. 此時在B跳C的action中設置 popUpTo=“@id/a”, popUpToInclusive=false. 跳到C后,此時棧中的順序是AC。B被出棧了。如果設置popUpToInclusive=true. 此時棧中的保留的就是C。AB都被出棧了。
在咱們的示例中,在注冊界面,用戶注冊完成后,希望直接返回首頁。這樣我們就需要在從RegisterFragment到HomeFragment的跳轉過程中,彈出之前棧中的首頁,登錄頁和注冊頁,添加如下配置既可達到我們想要的效果。

<fragment
    android:id="@+id/registerFragment"
    android:name="com.example.navicasetest.RegisterFragment"
    android:label="fragment_register"
    tools:layout="@layout/fragment_reg" >
    <action
        android:id="@+id/action_registerFragment_to_homeFragment"
        app:destination="@id/homeFragment"
        app:popUpTo="@id/homeFragment"
        app:popUpToInclusive="true"/>
</fragment>

五 DeepLink

Navigation組件提供了對深層鏈接(DeepLink)的支持。通過該特性,我們可以利用PendingIntent或者一個真實的URL鏈接,直接跳轉到應用程序的某個destination
下面我們分別看一下這兩種的使用方式。

1、PendingIntent

創建一個通知欄,通過Navigition 創建PendingIntent.


private void createNotification(){

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        int importance = NotificationManager.IMPORTANCE_DEFAULT;
        NotificationChannel channel = new NotificationChannel(CHANNEL_ID, "ChannelName", importance);
        channel.setDescription("description");
        NotificationManager notificationManager = getSystemService(NotificationManager.class);
        notificationManager.createNotificationChannel(channel);
    }

    NotificationCompat.Builder builder = new NotificationCompat.Builder(this, CHANNEL_ID)
            .setSmallIcon(R.drawable.ic_launcher_foreground)
            .setContentTitle("促銷水果")
            .setContentText("香蕉")
            .setPriority(NotificationCompat.PRIORITY_DEFAULT)
            .setContentIntent(getPendingIntent())//設置PendingIntent
            .setAutoCancel(true);

    NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this);
    notificationManager.notify(100001, builder.build());
}

private PendingIntent getPendingIntent() {
    Bundle bundle = new Bundle();
    bundle.putString("productName", "香蕉");
    bundle.putFloat("price",6.66f);
    return Navigation
            .findNavController(this,R.id.fragment)
            .createDeepLink()
            .setGraph(R.navigation.nav_graph)
            .setDestination(R.id.detailFragment)
            .setArguments(bundle)
            .createPendingIntent();
}

在DetailFragment, 解析傳參即可。參考上面的傳參小節。效果如下所示


notification
2、URL連接

URL的使用也比較簡單,我們下面給商品詳情頁(DetailFragment)添加deeplink支持,URL格式如下。
www.mywebsite.com/detail?productName={productName}price={price}
首先,需要在導航xml中,添加deeplink支持,添加完成xml如下

<fragment
    android:id="@+id/detailFragment"
    android:name="com.example.navicasetest.DetailFragment"
    android:label="fragment_detail"
    tools:layout="@layout/fragment_detail">
    <action
        android:id="@+id/action_detailFragment_to_payFragment"
        app:destination="@id/payFragment"
        app:enterAnim="@anim/slide_in_right"
        app:exitAnim="@anim/slide_out_left"
        app:popEnterAnim="@anim/slide_in_left"
        app:popExitAnim="@anim/slide_out_right" />
    <argument
        android:name="productName"
        android:defaultValue="unknow"
        app:argType="string" />
    <argument
        android:name="price"
        android:defaultValue="0.0f"
        app:argType="float" />
    <deepLink
        android:autoVerify="true"
        app:uri="www.mywebsite.com/detail?productName={productName}price={price}" />
</fragment>

然后,在Manifest文件中,添加如下配置

<nav-graph android:value="@navigation/nav_graph"/>

我們的DetailFragment中已經做了對參數productName和price的解析。
安裝app后,使用adb 命令測試deeplink連接

adb shell am start -a android.intent.action.VIEW -d "http://www.mywebsite.com/detail?productName="香蕉"price=10"

執行adb命令后,商品詳情頁被正常拉起。

五 場景對比

上面介紹了Navigation的基本用法,這一小節我們將構建一個頁面,分別看一下使用Navigation和不使用Navigation對頁面架構的影響。
在我們以往的項目開發過程中, 業務復雜且包含的模塊比較多的頁面, 我們經常用獨立的fragment來承擔不同的業務子頁面,但是fragment之間的跳轉,轉場動畫,以及回退棧管理,開發者需要自己實現相關邏輯。我們看下面的例子:


實現上面包含3個tab的首頁,常規做法是使用BottomNavigationView + fragment來搭架。代碼如下, 需要自己管理fragment的創建以及加載。

public class MainActivity2 extends AppCompatActivity {

    private int laseSelectPos = 0;
    private Fragment[] fragments;
    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main2);
        HomeFragment homeFragment = new HomeFragment();
        DashboardFragment dashboardFragment = new DashboardFragment();
        NotificationsFragment notificationsFragment = new NotificationsFragment();
        fragments = new Fragment[]{homeFragment, dashboardFragment, notificationsFragment};

        laseSelectPos = 0;
        
        getSupportFragmentManager()
                .beginTransaction()
                .add(R.id.fl_con, homeFragment)
                .show(homeFragment)//展示
                .commit();
        BottomNavigationView navView = findViewById(R.id.nav_vew_2);

        navView.setOnNavigationItemSelectedListener(new BottomNavigationView.OnNavigationItemSelectedListener() {
            @Override
            public boolean onNavigationItemSelected(@NonNull MenuItem item) {
                switch (item.getItemId()){
                    case R.id.navigation_home:
                        if (0 != laseSelectPos) {
                            setDefaultFragment(0);
                            laseSelectPos = 0;
                        }
                        return true;
                    case R.id.navigation_dashboard:
                        if (1 != laseSelectPos) {
                            setDefaultFragment(1);
                            laseSelectPos = 1;
                        }
                        return true;
                    case R.id.navigation_notifications:
                        if (2 != laseSelectPos) {
                            setDefaultFragment(2);
                            laseSelectPos = 2;
                        }
                        return true;
                }
                return false;
            }
        });
    }

    private void setDefaultFragment( int index) {
        FragmentTransaction transaction = getSupportFragmentManager().beginTransaction();
        transaction.replace(R.id.fl_con, fragments[index]);
        transaction.commit();
    }
}

配置文件如下:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingTop="?attr/actionBarSize">


    <com.google.android.material.bottomnavigation.BottomNavigationView
        android:id="@+id/nav_vew_2"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginStart="0dp"
        android:layout_marginEnd="0dp"
        android:background="?android:attr/windowBackground"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:menu="@menu/bottom_nav_menu" />

    <FrameLayout
        android:id="@+id/fl_con"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_constraintBottom_toTopOf="@+id/nav_vew_2"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent">

    </FrameLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

如果我們使用Navigation + BottomNavigationView來搭建上述要頁面
代碼如下:

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        BottomNavigationView navView = findViewById(R.id.nav_view);
        // Passing each menu ID as a set of Ids because each
        // menu should be considered as top level destinations.
        AppBarConfiguration appBarConfiguration = new AppBarConfiguration.Builder(
                R.id.navigation_home, R.id.navigation_dashboard, R.id.navigation_notifications)
                .build();
        NavController navController = Navigation.findNavController(this, R.id.nav_host_fragment);
        NavigationUI.setupActionBarWithNavController(this, navController, appBarConfiguration);
        NavigationUI.setupWithNavController(navView, navController);
    }

}

配置文件如下

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/container"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingTop="?attr/actionBarSize">

    <com.google.android.material.bottomnavigation.BottomNavigationView
        android:id="@+id/nav_view"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginStart="0dp"
        android:layout_marginEnd="0dp"
        android:background="?android:attr/windowBackground"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:menu="@menu/bottom_nav_menu" />

    <fragment
        android:id="@+id/nav_host_fragment"
        android:name="androidx.navigation.fragment.NavHostFragment"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:defaultNavHost="true"
        app:layout_constraintBottom_toTopOf="@id/nav_view"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:navGraph="@navigation/mobile_navigation" />

</androidx.constraintlayout.widget.ConstraintLayout>

比較上面2份代碼,明顯Navigation的方式實現更簡潔,框架幫我們做了好多創建和管理的工作,我們只要專注每個fragment的業務即可。例子中只是單純的展示fragment, 后面如果要加deeplink跳轉,轉場動畫等需求,就會更加體現navigation優勢。

六 源碼分析

Navigation暴露給開發者的就是NavHostFragment,NavController以及導航圖。導航圖又再xml文件中設置給了NavHostFragment。所以我們就主要分析這兩個類NavHostFragment和NavController。我們帶著下面幾個問題來分析下源碼:

  1. 導航圖是如何解析?
  2. 頁面跳轉是如何實現的?
  3. 為什么從一個靜態方法隨便傳入一個view,就能拿到NavController實例?
  4. 導航框架不僅支持fragment還支持activity, 是如何做到的?

為了避免大量的代碼影響閱讀體驗,后面的源碼分析只把關鍵的代碼做了展示,本文中未列出的代碼,讀者可以自行參考源碼。

1、NavHostFragment

要在某個Activity中實現導航,首先就是要在xml中引入NavHostFragment,xml中通過指定app:navGraph="@navigation/nav_graph"來指定導航圖, 那么應該是這個Fragment來負責解析并加載導航圖。我們就從這個Fragment創建流程入手,來看一下源碼。
1、onInflate 在這個流程中解析出我們上面提到的在xml配置的兩個參數defaultNavHost,
和navGraph,并保存在成員變量中 mGraphId,mDefaultNavHost。

final TypedArray navHost = context.obtainStyledAttributes(attrs,
                androidx.navigation.R.styleable.NavHost);
final int graphId = navHost.getResourceId(
        androidx.navigation.R.styleable.NavHost_navGraph, 0);
if (graphId != 0) {
    mGraphId = graphId;
}
navHost.recycle();

final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.NavHostFragment);
final boolean defaultHost = a.getBoolean(R.styleable.NavHostFragment_defaultNavHost, false);
if (defaultHost) {
    mDefaultNavHost = true;
}
a.recycle();

2、onCreate, 在OnCreate中,我們發現了NavController是在這里創建的, 這就說明一個導航圖對應一個NavController,在OnCreate中還把上面的mGraphId,設置給了NavController.

mNavController = new NavHostController(context);
//省略部分代碼
if (mGraphId != 0) {
    // Set from onInflate()
    mNavController.setGraph(mGraphId);
} else {
    // See if it was set by NavHostFragment.create()
    final Bundle args = getArguments();
    final int graphId = args != null ? args.getInt(KEY_GRAPH_ID) : 0;
    final Bundle startDestinationArgs = args != null
            ? args.getBundle(KEY_START_DESTINATION_ARGS)
            : null;
    if (graphId != 0) {
        mNavController.setGraph(graphId, startDestinationArgs);
    }
}

3、onCreateView 在這個函數中,只是創建了一個FragmentContainerView. 這個View是一個FrameLayout, 用于加載導航的Fragment

@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
                         @Nullable Bundle savedInstanceState) {
    FragmentContainerView containerView = new FragmentContainerView(inflater.getContext());
    // When added via XML, this has no effect (since this FragmentContainerView is given the ID
    // automatically), but this ensures that the View exists as part of this Fragment's View
    // hierarchy in cases where the NavHostFragment is added programmatically as is required
    // for child fragment transactions
    containerView.setId(getContainerId());
    return containerView;
}

4、onViewCreated 在這個函數中,把NavController設置給了父布局的view的中的ViewTag中。這里的設計比較關鍵,為什么要放到tag中呢?其實這樣的設計是為了讓我們外部獲取這個實例比較便捷,我們上面的問題3的答案就在這里,我們先看一下查找NavController的函數Navigation.findNavController(View),請注意API的設計,似乎傳遞任意一個 view的引用都可以獲取 NavController,這里就是通過遞歸遍歷view的父布局,查找是否有view含有id為R.id.nav_controller_view_tag的tag, tag有值就找到了NavController。如果tag沒有值.說明當前父容器沒有NavController.這里我們貼一下保存和查找的代碼。

public static void setViewNavController(@NonNull View view,
                                        @Nullable NavController controller) {
    view.setTag(R.id.nav_controller_view_tag, controller);
}

@Nullable
private static NavController findViewNavController(@NonNull View view) {
    while (view != null) {
        NavController controller = getViewNavController(view);
        if (controller != null) {
            return controller;
        }
        ViewParent parent = view.getParent();
        view = parent instanceof View ? (View) parent : null;
    }
    return null;
}
   

以上4步,就是NavHostFragment的主要工作,我們通過上面的分析可以看到,這個Fragment沒有承擔任何Destination的創建和導航工作。也沒有看到導航圖的解析工作,這個Fragment只是創建了個容器,創建了NavController,然后把只是單純的把mGraphId設置給了NavController。我們猜測導航的解析和創建工作應該都在NavController中。我們來看一下NavController的源碼。

2、NavController

導航的主要工作都在NavController中,涉及xml解析,導航堆棧管理,導航跳轉等方面。下面我們帶著上面剩余的3個問題,分析下NavController的實現。

  1. 上面我們提到NavHostFragment把導航文件的資源id傳給了NavController,我們繼續分析代碼發現,NavController把導航xml文件傳遞給了NavInflater, NavInflater主要負責解析導航xml文件,解析完畢后,生成NavGraph,NavGraph是個目標管理容器,保存著xml中配置的導航目標NavDestination。
@NonNull
private NavDestination inflate(@NonNull Resources res, @NonNull XmlResourceParser parser,
                               @NonNull AttributeSet attrs, int graphResId)
        throws XmlPullParserException, IOException {
    Navigator<?> navigator = mNavigatorProvider.getNavigator(parser.getName());
    final NavDestination dest = navigator.createDestination();

    dest.onInflate(mContext, attrs);

    final int innerDepth = parser.getDepth() + 1;
    int type;
    int depth;
    while ((type = parser.next()) != XmlPullParser.END_DOCUMENT
            && ((depth = parser.getDepth()) >= innerDepth
            || type != XmlPullParser.END_TAG)) {
        if (type != XmlPullParser.START_TAG) {
            continue;
        }

        if (depth > innerDepth) {
            continue;
        }

        final String name = parser.getName();
        if (TAG_ARGUMENT.equals(name)) {
            inflateArgumentForDestination(res, dest, attrs, graphResId);
        } else if (TAG_DEEP_LINK.equals(name)) {
            inflateDeepLink(res, dest, attrs);
        } else if (TAG_ACTION.equals(name)) {
            inflateAction(res, dest, attrs, parser, graphResId);
        } else if (TAG_INCLUDE.equals(name) && dest instanceof NavGraph) {
            final TypedArray a = res.obtainAttributes(
                    attrs, androidx.navigation.R.styleable.NavInclude);
            final int id = a.getResourceId(
                    androidx.navigation.R.styleable.NavInclude_graph, 0);
            ((NavGraph) dest).addDestination(inflate(id));
            a.recycle();
        } else if (dest instanceof NavGraph) {
            ((NavGraph) dest).addDestination(inflate(res, parser, attrs, graphResId));
        }
    }

    return dest;
}
  1. 導航目標解析完畢,具體的頁面跳轉是如何實現的呢,在使用過程中我們調用的是NavController的navigate函數,抽絲剝繭,發現導航最終調用的是Navigator的navigate函數。
Navigator<NavDestination> navigator = mNavigatorProvider.getNavigator(
        node.getNavigatorName());
Bundle finalArgs = node.addInDefaultArgs(args);
NavDestination newDest = navigator.navigate(node, finalArgs,
        navOptions, navigatorExtras);

我們看到導航的具體實現是Navigator,我們上面的例子是以Fragment為導航目標,但是Navigation 的目標對象不只是Fragment, 還可以是Activity,后面可能還會擴展其他種類, 這里谷歌把導航抽象成了Navigator,NavController中沒有持有具體的導航種類,而是持有的抽象類Navigator, 把所有Navigator的實例保存在了NavigatorProvider中. 這里就運用了設計模式中的依賴倒置原則,要面向接口編程,而不是具體實現。同時也符合了開閉原則,后面在擴展新的導航種類,不會影響到現有的種類。通過以上的分析,問題2和問題4也就得到了解答。
我們以FragmentNavigator為例,看一下具體的導航邏輯的實現。只分析部分關鍵代碼片段

String className = destination.getClassName();
if (className.charAt(0) == '.') {
    className = mContext.getPackageName() + className;
}
final Fragment frag = instantiateFragment(mContext, mFragmentManager,
        className, args);
......
frag.setArguments(args);
final FragmentTransaction ft = mFragmentManager.beginTransaction();



ft.replace(mContainerId, frag);
ft.setPrimaryNavigationFragment(frag);

從以上代碼可以看出,Fragment實例是通過instantiateFragment創建的,這個函數中是通過反射的方式創建的Fragment實例,Fragment還是通過FragmentManager進行管理,是用replace方法替換新的Fragment, 這就是說每次導航產生的Fragment都是一個新的實例,不會保存之前Fragment的狀態。這樣的話,可能會造成數據不同步的現象。所以google建議導航和ViewModel配合使用效果更佳。

綜上所述,NavController是導航的核心類,它負責頁面加載,頁面導航,和堆棧管理。但是這些邏輯沒有都耦合在這個類中,而是采用組合的方式,把這些實現都拆分成了單獨的模塊。NavController需要實現哪些功能,調用相應功能即可。

七 總結

上面我們列舉了導航的基本用法以及源碼分析,通過上面的學習,大家也了解到了,導航組件是一個頁面的管理框架,創建簡潔,使用方便,在構架業務復雜的頁面時,架構清晰,功能多樣,可以使開發者可以專注于業務邏輯的開發,是一個優秀的框架。我們在學習的過程中,不僅要學會如何使用,還要深入的學習其架構原理,為我們以后的項目架構,提供可借鑒的方案。

參考文獻:
https://developer.android.google.cn/guide/navigation/navigation-getting-started
http://www.lxweimin.com/p/ad040aab0e66

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。