手撕Jetpack組件之Navigation

前言

Navigation庫并不像LifecycleLiveDataViewModel能夠優(yōu)雅地解決我們在開發(fā)中常遇到的問題种呐。它只是對我們以前在ActivityFragment的界面跳轉(zhuǎn)方式做了統(tǒng)一的封裝汁讼,可以讓開發(fā)者減少一些模版代碼的編寫,提升開發(fā)效率耸彪。

簡單使用

  1. 添加依賴
implementation 'androidx.navigation:navigation-fragment:2.3.5'
implementation 'androidx.navigation:navigation-ui:2.3.5'
  1. 創(chuàng)建兩個Fragment,分別叫做FirstFragmentSecondFragment

  2. 在res目錄下新建一個名為navigation文件夾蜀肘,在文件夾內(nèi)新建一個名為demo_navigation.xml文件,文件內(nèi)容如下:

<?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/mobile_navigation"
    app:startDestination="@+id/first_fragment">

    <!--  startDestination表示要加載首頁Fragment  -->

    <fragment
        android:id="@+id/first_fragment"
        android:name="com.pbl.navigation.navigationdemo.ui.fragment.FirstFragment"
        android:label="第一個fragment"
        tools:layout="@layout/fragment_first" />

    <fragment
        android:id="@+id/second_fragment"
        android:name="com.pbl.navigation.navigationdemo.ui.fragment.SecondFragment"
        android:label="第二個fragment"
        tools:layout="@layout/fragment_second" />
</navigation>
  1. MainActivity的布局文件中添加承載Fragment的容器
<?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">

    <!--
        defaultNavHost表示是否要攔截系統(tǒng)返回鍵薄腻,在Fragment界面罢艾,
        攔截它是用來處理Fragment回退棧問題
    -->

    <!--
        navGraph給示導航圖童漩,也就是我們所有Fragment都會被囊括在這個圖內(nèi)期奔,
        這樣Navigation框架就可以根據(jù)id導航到目標Fragment
    -->


    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/nav_host_fragment_activity_main"
        android:name="androidx.navigation.fragment.NavHostFragment"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:defaultNavHost="true"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:navGraph="@navigation/demo_navigation" />

</androidx.constraintlayout.widget.ConstraintLayout>

從上面可以看出弥搞,承載Fragment的容器是FragmentContainerView,它是一個FrameLayout,而它承載的第一個Fragmentandroidx.navigation.fragment.NavHostFragment。很顯然,這是一個占位Fragment赌躺。我們的FirstFragment在顯示的時候肯定會替換它掠归,因此可以推測出會調(diào)用Fragmentreplace方法码泞。

  1. MainActivityonCreate使用它
// 使用1
NavHostFragment fragment = (NavHostFragment) getSupportFragmentManager()
        .findFragmentById(R.id.nav_host_fragment_activity_main);
// 使用2
NavController controller = fragment.getNavController();
// 使用3
NavigationUI.setupActionBarWithNavController(this, controller);
  1. FirstFragment中有一個Button跳轉(zhuǎn)到SecondFragment, 它的onClickListener的實現(xiàn)為:
// 跳轉(zhuǎn)到某個Fragment
Navigation.findNavController(v).navigate(R.id.second_fragment);

// 返回上個Fragment
// Navigation.findNavController(v).navigateUp();

源碼分析

使用1處的代碼宋舷,以前在開發(fā)中使用Fragment時也會遇到绎狭,所以對于我們來說是不陌生的。通過xml文件中標明的id,找到Navigation框架給我們提供的占位Fragment: NavHostFragment摆屯。接下來我們就來看看這個Fragment的生命周期做了一些什么事富弦。

@CallSuper
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
    final Context context = requireContext();
    // 注釋1
    mNavController = new NavHostController(context);
    // Lifecycle相關(guān)
    mNavController.setLifecycleOwner(this);
    // 返回鍵相關(guān)
    mNavController.setOnBackPressedDispatcher(requireActivity().getOnBackPressedDispatcher());
    // Set the default state - this will be updated whenever
    // onPrimaryNavigationFragmentChanged() is called
    mNavController.enableOnBackPressed(
            mIsPrimaryBeforeOnCreate != null && mIsPrimaryBeforeOnCreate);
    mIsPrimaryBeforeOnCreate = null;
    // ViewModel相關(guān)
    mNavController.setViewModelStore(getViewModelStore());
    // 注釋2
    onCreateNavController(mNavController);

    ...
    // 注釋3
    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);
        }
    }
    super.onCreate(savedInstanceState);
}

先看注釋1部分

public NavController(@NonNull Context context) {
    mContext = context;
    while (context instanceof ContextWrapper) {
        if (context instanceof Activity) {
            mActivity = (Activity) context;
            break;
        }
        context = ((ContextWrapper) context).getBaseContext();
    }
    mNavigatorProvider.addNavigator(new NavGraphNavigator(mNavigatorProvider));
    mNavigatorProvider.addNavigator(new ActivityNavigator(mContext));
}

NavHostController繼承于NavController蓖扑,創(chuàng)建其對象時就做了一件事柜去,添加了兩個Navigator浑厚,Navigator是用來控制界面導航的基類物蝙。在這里添加了一個ActivityNavigator,那肯定還會有一個FragmentNavigator

看看注釋2部分

@CallSuper
protected void onCreateNavController(@NonNull NavController navController) {
    navController.getNavigatorProvider().addNavigator(
            new DialogFragmentNavigator(requireContext(), getChildFragmentManager()));
    navController.getNavigatorProvider().addNavigator(createFragmentNavigator());
}


@Deprecated
@NonNull
protected Navigator<? extends FragmentNavigator.Destination> createFragmentNavigator() {
    return new FragmentNavigator(requireContext(), getChildFragmentManager(),
            getContainerId());
}

上面我們猜對了,又添加了兩個Navigator。我們繼續(xù)看注釋3部分湃望。到底是執(zhí)行if里面代碼還是else里面的代碼呢?我們先來看看這個mGraphId是在哪里賦值的。

@CallSuper
@Override
public void onInflate(@NonNull Context context, @NonNull AttributeSet attrs,
        @Nullable Bundle savedInstanceState) {
    super.onInflate(context, attrs, savedInstanceState);

    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();
}

onInflate方法先于onCreate執(zhí)行官硝,它主要做了一件事傻咖,獲取xml文件中自定義屬性『τ伲回看上文簡單用法的第4步添加的xml內(nèi)容,有兩個自定義的屬性溪王,一個是navGraph,通過這個graphId就可以獲取我們寫在res/navigation/demo_navigation.xml里面的內(nèi)容了道伟。另一個是defaultNavHost,是否需要攔截系統(tǒng)返回鍵。

我們再回到注釋3盆色,很顯然這里會執(zhí)行if語句塊里面的代碼,我們繼續(xù)跟進:

@CallSuper
public void setGraph(@NavigationRes int graphResId, @Nullable Bundle startDestinationArgs) {
    setGraph(getNavInflater().inflate(graphResId), startDestinationArgs);
}

inflate方法應該就是去解析我們那個demo_navigation.xml文件宣旱,來繼續(xù)跟蹤源碼

@SuppressLint("ResourceType")
@NonNull
public NavGraph inflate(@NavigationRes int graphResId) {
    Resources res = mContext.getResources();
    XmlResourceParser parser = res.getXml(graphResId);
    final AttributeSet attrs = Xml.asAttributeSet(parser);
    try {
        int type;
        while ((type = parser.next()) != XmlPullParser.START_TAG
                && type != XmlPullParser.END_DOCUMENT) {
            // Empty loop
        }
        if (type != XmlPullParser.START_TAG) {
            throw new XmlPullParserException("No start tag found");
        }

        String rootElement = parser.getName();
        // 真正解析xml文件在這里
        NavDestination destination = inflate(res, parser, attrs, graphResId);
        if (!(destination instanceof NavGraph)) {
            throw new IllegalArgumentException("Root element <" + rootElement + ">"
                    + " did not inflate into a NavGraph");
        }
        return (NavGraph) destination;
    } catch (Exception e) {
        throw new RuntimeException("Exception inflating "
                + res.getResourceName(graphResId) + " line "
                + parser.getLineNumber(), e);
    } finally {
        parser.close();
    }
}

@NonNull
private NavDestination inflate(@NonNull Resources res, @NonNull XmlResourceParser parser,
        @NonNull AttributeSet attrs, int graphResId)
        throws XmlPullParserException, IOException {
    // 注釋4
    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) {
            // 注釋5
            ((NavGraph) dest).addDestination(inflate(res, parser, attrs, graphResId));
        }
    }

    return dest;
}

注釋4處的navigator對象到底是指哪一個呢粪糙?再回看上文的注釋1部分說明,添加的第一個navigator對象就是NavGraphNavigator寞酿。它在這里做了一件事伐弹,通過createDestination方法創(chuàng)建了一個NavDestination對象,它是用來干嘛的呢日川?

根據(jù)注釋5得知,原來每一個fragment標簽信息都會被封裝成一個個NavDestination對象,這些destination對象的管者理就是NavGraph對象欧漱。

@CallSuper
public void setGraph(@NonNull NavGraph graph, @Nullable Bundle startDestinationArgs) {
    if (mGraph != null) {
        // Pop everything from the old graph off the back stack
        popBackStackInternal(mGraph.getId(), true);
    }
    mGraph = graph;
    onGraphCreated(startDestinationArgs);
}

當demo_navigation.xml被解析成一個NavGraph對象后,通過這個對象跳轉(zhuǎn)到

private void onGraphCreated(@Nullable Bundle startDestinationArgs) {

    ...
    // 首次進來硫椰,后退棧mBackStack數(shù)據(jù)是為空的
    if (mGraph != null && mBackStack.isEmpty()) {
        // mDeepLinkHandled第一次進來時為false
        boolean deepLinked = !mDeepLinkHandled && mActivity != null
                && handleDeepLink(mActivity.getIntent());
        if (!deepLinked) {
            // Navigate to the first destination in the graph
            // if we haven't deep linked to a destination
            // 導航到導航圖中的第一個界面
            navigate(mGraph, startDestinationArgs, null, null);
        }
    } else {
        dispatchOnDestinationChanged();
    }
}

private void navigate(@NonNull NavDestination node, @Nullable Bundle args,
        @Nullable NavOptions navOptions, @Nullable Navigator.Extras navigatorExtras) {
    boolean popped = false;
    boolean launchSingleTop = false;
    if (navOptions != null) {
        if (navOptions.getPopUpTo() != -1) {
            popped = popBackStackInternal(navOptions.getPopUpTo(),
                    navOptions.isPopUpToInclusive());
        }
    }
    // 這里由于是導航到第一個Fragment界面了靶草,所以這個navigator對象是FragmentNavigator
    Navigator<NavDestination> navigator = mNavigatorProvider.getNavigator(
            node.getNavigatorName());
    Bundle finalArgs = node.addInDefaultArgs(args);
    NavDestination newDest = navigator.navigate(node, finalArgs,
            navOptions, navigatorExtras);
    ...
}

我們繼續(xù)跟進到FragmentNavigator#navigate方法中

@Nullable
@Override
public NavDestination navigate(@NonNull Destination destination, @Nullable Bundle args,
        @Nullable NavOptions navOptions, @Nullable Navigator.Extras navigatorExtras) {
    ...
    
    String className = destination.getClassName();
    if (className.charAt(0) == '.') {
        className = mContext.getPackageName() + className;
    }
    // 通過反射創(chuàng)建一個Fragment對象
    final Fragment frag = instantiateFragment(mContext, mFragmentManager,
            className, args);
    frag.setArguments(args);
    final FragmentTransaction ft = mFragmentManager.beginTransaction();
    
    // Fragment跳轉(zhuǎn)動畫相關(guān)
    int enterAnim = navOptions != null ? navOptions.getEnterAnim() : -1;
    int exitAnim = navOptions != null ? navOptions.getExitAnim() : -1;
    int popEnterAnim = navOptions != null ? navOptions.getPopEnterAnim() : -1;
    int popExitAnim = navOptions != null ? navOptions.getPopExitAnim() : -1;
    if (enterAnim != -1 || exitAnim != -1 || popEnterAnim != -1 || popExitAnim != -1) {
        enterAnim = enterAnim != -1 ? enterAnim : 0;
        exitAnim = exitAnim != -1 ? exitAnim : 0;
        popEnterAnim = popEnterAnim != -1 ? popEnterAnim : 0;
        popExitAnim = popExitAnim != -1 ? popExitAnim : 0;
        ft.setCustomAnimations(enterAnim, exitAnim, popEnterAnim, popExitAnim);
    }

    // 使用replace方法岳遥,我們的首頁Fragment就在這里替換掉了占位NavHostFragment
    ft.replace(mContainerId, frag);
    ft.setPrimaryNavigationFragment(frag);

    ...
    
    // FragmentTransaction事務提交
    ft.commit();
    // The commit succeeded, update our view of the world
    if (isAdded) {
        // 添加到后退棧中
        mBackStack.add(destId);
        return destination;
    } else {
        return null;
    }
}

分析到這里,我們已經(jīng)知道了Navigation框架是如何通過我們配置的demo_navigation.xml文件加載我們的首頁Fragment了浩蓉。再來看看從第一個Fragment跳轉(zhuǎn)到第二個Fragment都做了一些什么宾袜?

Navigation.findNavController(root).navigate(R.id.second_fragment);

先找到NavController對象庆猫,從這里可以看出绅络,導航的總控制器是這個對象。繼續(xù)跟進這個navigate方法恩急,最終會執(zhí)行下面這個方法

public void navigate(@IdRes int resId, @Nullable Bundle args, @Nullable NavOptions navOptions,
        @Nullable Navigator.Extras navigatorExtras) {
    // 因為我們已經(jīng)加載了第一個FirstFragment了,所以此時mBackStack不為空此叠,
    // 那么currentNode就是FirstFragment對應的NavDestination
    NavDestination currentNode = mBackStack.isEmpty()
            ? mGraph
            : mBackStack.getLast().getDestination();
    if (currentNode == null) {
        throw new IllegalStateException("no current navigation node");
    }
    @IdRes int destId = resId;
    final NavAction navAction = currentNode.getAction(resId);
    Bundle combinedArgs = null;
    if (navAction != null) {
        if (navOptions == null) {
            navOptions = navAction.getNavOptions();
        }
        destId = navAction.getDestinationId();
        Bundle navActionArgs = navAction.getDefaultArguments();
        if (navActionArgs != null) {
            combinedArgs = new Bundle();
            combinedArgs.putAll(navActionArgs);
        }
    }

    if (args != null) {
        if (combinedArgs == null) {
            combinedArgs = new Bundle();
        }
        combinedArgs.putAll(args);
    }

    if (destId == 0 && navOptions != null && navOptions.getPopUpTo() != -1) {
        popBackStack(navOptions.getPopUpTo(), navOptions.isPopUpToInclusive());
        return;
    }

    if (destId == 0) {
        throw new IllegalArgumentException("Destination id == 0 can only be used"
                + " in conjunction with a valid navOptions.popUpTo");
    }
    // 通過我們傳入的R.id.second_fragment這個ID值去查找對應的NavDestination
    // 去哪里查找随珠?上文有提到過所有界面的NavDestination都是由NavGraph對象管理的
    NavDestination node = findDestination(destId);
    if (node == null) {
        final String dest = NavDestination.getDisplayName(mContext, destId);
        if (navAction != null) {
            throw new IllegalArgumentException("Navigation destination " + dest
                    + " referenced from action "
                    + NavDestination.getDisplayName(mContext, resId)
                    + " cannot be found from the current destination " + currentNode);
        } else {
            throw new IllegalArgumentException("Navigation action/destination " + dest
                    + " cannot be found from the current destination " + currentNode);
        }
    }
    // 這里就會執(zhí)行到我們上文分析過的navigate方法了
    navigate(node, combinedArgs, navOptions, navigatorExtras);
}

源碼差不多分析完了,最后用一張類UML圖來總結(jié)一相涉及到的相關(guān)類牙丽。


navigation.png

從類的UML圖可以看出烤芦,Navigation框架使用了設計模式中的策略模式举娩,Navigator定義了一個導航規(guī)則构罗,具體怎么導航由子類來實現(xiàn)。這里的子類就是圖中的ActivityNavigator遂唧、FragmentNavigator等。

遇到的問題

  • 通過上面源碼分析纹烹,Fragment的加載都是通過replace方法,也就意味著每次回退時界面都會重新渲染铺呵,很顯然這不符合我們的開發(fā)需求
  • Fragment只能在xml文件配置隧熙,若是分模塊開發(fā)片挂,無法滿足業(yè)務需求

下篇文章將會介紹如何優(yōu)雅地解決上面的兩個問題。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末沪饺,一起剝皮案震驚了整個濱河市闷愤,隨后出現(xiàn)的幾起案子整葡,更是在濱河造成了極大的恐慌肝谭,老刑警劉巖,帶你破解...
    沈念sama閱讀 218,036評論 6 506
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件魏滚,死亡現(xiàn)場離奇詭異坟漱,居然都是意外死亡鼠次,警方通過查閱死者的電腦和手機芋齿,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,046評論 3 395
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來觅捆,“玉大人,你說我怎么就攤上這事掂摔。” “怎么了乙漓?”我有些...
    開封第一講書人閱讀 164,411評論 0 354
  • 文/不壞的土叔 我叫張陵释移,是天一觀的道長。 經(jīng)常有香客問我玩讳,道長,這世上最難降的妖魔是什么熏纯? 我笑而不...
    開封第一講書人閱讀 58,622評論 1 293
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上掐场,老公的妹妹穿的比我還像新娘贩猎。我一直安慰自己萍膛,他們只是感情好吭服,可當我...
    茶點故事閱讀 67,661評論 6 392
  • 文/花漫 我一把揭開白布蝗罗。 她就那樣靜靜地躺著,像睡著了一般串塑。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上打瘪,一...
    開封第一講書人閱讀 51,521評論 1 304
  • 那天傻昙,我揣著相機與錄音,去河邊找鬼妆档。 笑死,一個胖子當著我的面吹牛贾惦,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播纤虽,決...
    沈念sama閱讀 40,288評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼洋措!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起菠发,我...
    開封第一講書人閱讀 39,200評論 0 276
  • 序言:老撾萬榮一對情侶失蹤贺嫂,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后第喳,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,644評論 1 314
  • 正文 獨居荒郊野嶺守林人離奇死亡悠抹,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,837評論 3 336
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了楔敌。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 39,953評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡庆聘,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出伙判,到底是詐尸還是另有隱情,我是刑警寧澤澳腹,帶...
    沈念sama閱讀 35,673評論 5 346
  • 正文 年R本政府宣布杨何,位于F島的核電站酱塔,受9級特大地震影響危虱,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜埃跷,卻給世界環(huán)境...
    茶點故事閱讀 41,281評論 3 329
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望垃帅。 院中可真熱鬧剪勿,春花似錦贸诚、人聲如沸厕吉。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,889評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至项钮,卻和暖如春希停,著一層夾襖步出監(jiān)牢的瞬間鳖敷,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,011評論 1 269
  • 我被黑心中介騙來泰國打工定踱, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留恃鞋,地道東北人。 一個月前我還...
    沈念sama閱讀 48,119評論 3 370
  • 正文 我出身青樓畅哑,卻偏偏與公主長得像,于是被迫代替她去往敵國和親荠呐。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 44,901評論 2 355

推薦閱讀更多精彩內(nèi)容