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位置)。
在可視化編輯模式下,點擊左上角的 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>
通過上面的配置,我們就完整的創建了一個導航圖。如下圖所示
下面就需要把導航添加到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文件夾下會生成幾個文件。如下圖
在首頁的跳轉函數中,寫下如下代碼
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, 解析傳參即可。參考上面的傳參小節。效果如下所示
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。我們帶著下面幾個問題來分析下源碼:
- 導航圖是如何解析?
- 頁面跳轉是如何實現的?
- 為什么從一個靜態方法隨便傳入一個view,就能拿到NavController實例?
- 導航框架不僅支持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的實現。
- 上面我們提到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;
}
- 導航目標解析完畢,具體的頁面跳轉是如何實現的呢,在使用過程中我們調用的是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