View 的創(chuàng)建 - LayoutInflater 基礎(chǔ)流程分析

LayoutInflater 將布局文件(XML)實(shí)例化為一個 View 對象。

通常我們會通過 Activity#getLayoutInflater() 或者是 context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) 來獲取一個標(biāo)準(zhǔn)的與當(dāng)前運(yùn)行的 Context 相關(guān)聯(lián)的 LayoutInflater 實(shí)例。

我們以 Activity#setContentView(@LayoutRes int layoutResID) 為例來看一下 LayoutInflater 的工作流程。

源碼:Android-29、AndroidX

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    }
}

1 AppCompatActivity#setContentView 流程

AppCompatActivity 是 AndroidX 兼容包下的 Activity 的實(shí)現(xiàn)基類

//#AppCompatActivity
@Override
public void setContentView(@LayoutRes int layoutResID) {
    getDelegate().setContentView(layoutResID);
}

可以看到內(nèi)部通過代理來進(jìn)行設(shè)置,下面來看代理的實(shí)現(xiàn) getDelegate()

////#AppCompatActivity
@NonNull
public AppCompatDelegate getDelegate() {
    if (mDelegate == null) {
        mDelegate = AppCompatDelegate.create(this, this);
    }
    return mDelegate;
}

@NonNull
public static AppCompatDelegate create(@NonNull Activity activity,
        @Nullable AppCompatCallback callback) {
    return new AppCompatDelegateImpl(activity, callback);
}

通過 getDelegate() 方法創(chuàng)建了代理的實(shí)現(xiàn)類 AppCompatDelegateImpl,下面我們來看這個代理實(shí)現(xiàn)類中的 setContentView 方法:

@Override
public void setContentView(int resId) {
    ensureSubDecor();
    ViewGroup contentParent = mSubDecor.findViewById(android.R.id.content);
    contentParent.removeAllViews();
    LayoutInflater.from(mContext).inflate(resId, contentParent);
    mAppCompatWindowCallback.getWrapped().onContentChanged();
}

可以看見,AppCompatActivity#setContentView 內(nèi)部是通過 LayoutInflater.from(mContext).inflate(resId, contentParent) 來加載布局的

2 LayoutInflater.from(mContext) 流程

在來看 from(mContext) 方法之前,我們先明確 mContext 的類型。

2.1 mContext 的類型

往上翻可以看到我們在創(chuàng)建代理對象的同時,將 Activity 作為參數(shù)傳入,也就是說 mContext 的類型是 AppCompatActivity 也就是 ContextThemeWrapper 類型。

這里我們貼上一張 Context 相關(guān)的背景知識:


Context -android-29-

2.2 LayoutInflater.from(mContext)

/**
 * Obtains the LayoutInflater from the given context.
 */
public static LayoutInflater from(Context context) {
    LayoutInflater LayoutInflater =
            (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
    if (LayoutInflater == null) {
        throw new AssertionError("LayoutInflater not found.");
    }
    return LayoutInflater;
}

可以發(fā)現(xiàn)我們是通過 context.getSystemService 來獲取 LayoutInflater “服務(wù)”。
之前我們已經(jīng)明確了 mContext 的類型是 ContextThemeWrapper 類型,我們來看看它的 from 方法:

 @Override
 public Object getSystemService(String name) {
     if (LAYOUT_INFLATER_SERVICE.equals(name)) {
         if (mInflater == null) {
             mInflater = LayoutInflater.from(getBaseContext()).cloneInContext(this);
         }
         return mInflater;
     }
     return getBaseContext().getSystemService(name);
 }

這個的 getBaseContext 獲取到的是 mBase 這個屬性,它的類型是 ContextImpl,mBase 的賦值源于 Activity 在創(chuàng)建后的調(diào)用的 attach 方法,這里就不再展開了。

然后讓我們來看看 ContextImplgetSystemService 方法:

@Override
public Object getSystemService(String name) {
    return SystemServiceRegistry.getSystemService(this, name);
}

到這里看上去是真正要獲取服務(wù)的地方了,我們根據(jù) ContextImpl 對象本身和服務(wù)名稱SystemServiceRegistry 中獲取服務(wù),我們可以把它理解為注冊表:

public static Object getSystemService(ContextImpl ctx, String name) {
    ServiceFetcher<?> fetcher = SYSTEM_SERVICE_FETCHERS.get(name);
    return fetcher != null ? fetcher.getService(ctx) : null;
}

跟進(jìn)到它的 getSystemService 方法中可以看到:

  1. 從 SYSTEM_SERVICE_FETCHERS 中獲取 ServiceFetcher
  2. 通過 ServiceFetcher 獲取服務(wù)

服務(wù)在什么時候注冊?
SystemServiceRegistry 的靜態(tài)代碼塊中,對服務(wù)進(jìn)行了注冊:

registerService(Context.LAYOUT_INFLATER_SERVICE, LayoutInflater.class,
        new CachedServiceFetcher<LayoutInflater>() {
    @Override
    public LayoutInflater createService(ContextImpl ctx) {
        return new PhoneLayoutInflater(ctx.getOuterContext());
    }});

可以看到最終我們獲取到的 LayoutInflater 類型是 PhoneLayoutInflater 類型。

在 ContextImpl 類中有一個 mServiceCache 屬性,在聲明的時候已經(jīng)初始化:

//ContextImpl
// The system service cache for the system services that are cached per-ContextImpl.
    @UnsupportedAppUsage
    final Object[] mServiceCache = SystemServiceRegistry.createServiceCache();

由此可見,在 ContextImpl 創(chuàng)建后,服務(wù)就會進(jìn)行注冊,根據(jù)注釋可知,每一個 ContextImpl 對象都有自己的服務(wù)緩存。

ServiceFetcher如何獲取服務(wù)?
我們在回過頭來看服務(wù)獲取的具體邏輯(我刪除了部分代碼):

public final T getService(ContextImpl ctx) {
    final Object[] cache = ctx.mServiceCache;
    final int[] gates = ctx.mServiceInitializationStateArray;
    for (;;) {
        boolean doInitialize = false;
        synchronized (cache) {
            // Return it if we already have a cached instance.
            //①
            T service = (T) cache[mCacheIndex];
            if (service != null || gates[mCacheIndex] == ContextImpl.STATE_NOT_FOUND) {
                return service;
            }
         if (doInitialize) {
            // Only the first thread gets here.
            T service = null;
            try {
            //②
                service = createService(ctx);
                newState = ContextImpl.STATE_READY;
            } catch (ServiceNotFoundException e) {
                onServiceNotFound(e);
            } finally {
                synchronized (cache) {
                    ③
                    cache[mCacheIndex] = service;
                    gates[mCacheIndex] = newState;
                    cache.notifyAll();
                }
            }
            return service;
        }
    }
}
  1. 查看 ContextImpl 的緩存,緩存命中則直接返回服務(wù)
  2. 緩存不存在的話則通過 createService 方法創(chuàng)建服務(wù)
  3. 最后將服務(wù)緩存在 ContextImpl 中

2.3 PhoneLayoutInflater#cloneInContext

在上面 ContextThemeWrapper 獲取服務(wù)的代碼中 mInflater = LayoutInflater.from(getBaseContext()).cloneInContext(this); 我們在獲取 PhoneLayoutInflater 之后,還調(diào)用了 cloneInContext 方法,名字聽上去是克隆一個對象。

public LayoutInflater cloneInContext(Context newContext) {
    return new PhoneLayoutInflater(this, newContext);
}

protected PhoneLayoutInflater(LayoutInflater original, Context newContext) {
    super(original, newContext);
}

protected LayoutInflater(LayoutInflater original, Context newContext) {
    mContext = newContext;
    mFactory = original.mFactory;
    mFactory2 = original.mFactory2;
    mPrivateFactory = original.mPrivateFactory;
    setFilter(original.mFilter);
    initPrecompiledViews();
}

由此可見我們使用了一個新的 Context 替換了原先 LayoutInflater 中的 mContext 屬性,根據(jù)上面的代碼可知,我們在使用 ContextImpl 創(chuàng)建了 PhoneLayoutInflater 之后,將其中的 mContext 替換為 ContextThemeWrapper。

3 LayoutInflater#inflate

LayoutInflater.from(mContext).inflate(resId, contentParent); 由上面分析可知,在獲取到 PhoneLayoutInflater 對象后,接著調(diào)用它的 inflate 方法:

public View inflate(@LayoutRes int resource, @Nullable ViewGroup root) {
    return inflate(resource, root, root != null);
}

public View inflate(@LayoutRes int resource, @Nullable ViewGroup root, boolean attachToRoot) {
    final Resources res = getContext().getResources();
    if (DEBUG) {
        Log.d(TAG, "INFLATING from resource: \"" + res.getResourceName(resource) + "\" ("
              + Integer.toHexString(resource) + ")");
    }
    View view = tryInflatePrecompiled(resource, res, root, attachToRoot);
    if (view != null) {
        return view;
    }
    XmlResourceParser parser = res.getLayout(resource);
    try {
        return inflate(parser, root, attachToRoot);
    } finally {
        parser.close();
    }
}

最終調(diào)用的 inflate 方法接收三個參數(shù):

  1. @LayoutRes int resource 這個參數(shù)就是我們 setContentView 方法傳入的 XML 資源文件
  2. @Nullable ViewGroup root 這是一個可選的父布局容器
  3. boolean attachToRoot ,這個參數(shù)決定是否將從 XML 加載的 View 對象添加進(jìn) root 中

根據(jù) setContentView 的調(diào)用可知,我們傳入的布局文件最后會被添加進(jìn) id 為 Content 的容器中

在 inflate 方法中我們創(chuàng)建了當(dāng)前 XML 資源文件的解析器,并將它傳入重載的 inflate 房中:

public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
        synchronized (mConstructorArgs) {
            View result = root;
            try {
                //① merge 標(biāo)簽判斷
                if (TAG_MERGE.equals(name)) {
                    if (root == null || !attachToRoot) {
                        ...
                    }
                    rInflate(parser, root, inflaterContext, attrs, false);
                } else {
                    // ②創(chuàng)建 XMl 文件中頂層標(biāo)簽中標(biāo)記的 View 對象
                    final View temp = createViewFromTag(root, name, inflaterContext, attrs);
                    ViewGroup.LayoutParams params = null;

                    if (root != null) {
                    // ③指定 LayoutParams
                        params = root.generateLayoutParams(attrs);
                        if (!attachToRoot) {
                            // Set the layout params for temp if we are not
                            // attaching. (If we are, we use addView, below)
                            temp.setLayoutParams(params);
                        }
                    }

                    //④遞歸的將子視圖填充到 temp 中
                    context.rInflateChildren(parser, temp, attrs, true);

                    //⑤將根據(jù) XML 實(shí)例化的 View 添加到 root 中                    
                    if (root != null && attachToRoot) {
                        root.addView(temp, params);
                    }

                    //直接返回 temp 對象
                    if (root == null || !attachToRoot) {
                        result = temp;
                    }
                }

            } catch (XmlPullParserException e) {
                ....
            }

            return result;
        }
    }

這里的方法有點(diǎn)長,我同樣省略一些代碼,讓我們的視線集中在布局的解析上。

3.1 merge 標(biāo)簽

我們判斷 XMl 的根標(biāo)簽是不是 merge,如果是 merge 標(biāo)記的話在直接調(diào)用遞歸填充方法 rInflater,這里我們先不看對 merge 標(biāo)簽的處理。

3.2 LayoutInflater#createViewFromTag

如果不是 merge 標(biāo)簽的話,我們將調(diào)用 createViewFromTag 方法來創(chuàng)建 View 對象:

View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
            boolean ignoreThemeAttr) {
        //...
        try {
            View view = tryCreateView(parent, name, context, attrs);

            if (view == null) {
                final Object lastContext = mConstructorArgs[0];
                mConstructorArgs[0] = context;
                try {
                    if (-1 == name.indexOf('.')) {
                        view = onCreateView(context, parent, name, attrs);
                    } else {
                        view = createView(context, name, null, attrs);
                    }
                } finally {
                    mConstructorArgs[0] = lastContext;
                }
            }

            return view;
        } catch (InflateException e) {
            //...
        }
    }

這里也是分為三步:

  1. 嘗試調(diào)用 tryCreateView 方法創(chuàng)建 View
  2. 如果不是自定義 View 則調(diào)用 onCreateView 方法
  3. 如果是自定義 View 則調(diào)用 createView

3.2.1 LayoutInflater#tryCreateView

public final View tryCreateView(@Nullable View parent, @NonNull String name,
    @NonNull Context context,
    @NonNull AttributeSet attrs) {
    if (name.equals(TAG_1995)) {
        // Let's party like it's 1995!
        return new BlinkLayout(context, attrs);
    }
    View view;
    if (mFactory2 != null) {
        view = mFactory2.onCreateView(parent, name, context, attrs);
    } else if (mFactory != null) {
        view = mFactory.onCreateView(name, context, attrs);
    } else {
        view = null;
    }
    if (view == null && mPrivateFactory != null) {
        view = mPrivateFactory.onCreateView(parent, name, context, attrs);
    }
    return view;
}
  1. 如果 XML 的標(biāo)記是 blink(TAG_1995) 的話,會創(chuàng)建一個 BlinkLayout 類型的對象,它會每隔 500ms 重繪一次,形成閃爍的效果。
  2. 嘗試通過 mFactory2 對象的 onCreateView 來創(chuàng)建 View 對象
  3. 嘗試通過 mFactory 對象的 onCreateView 來創(chuàng)建 View 對象
  4. 嘗試通過 mPrivateFactory 對象的 onCreateView 來創(chuàng)建 View 對象

mFactory2、mPrivateFactory 對象都是 Factory2 類型,而 mFactory 對象是 Factory 類型,F(xiàn)actory2 是對 Factory 接口的升級,F(xiàn)actory2 繼承了 Factory 接口,并且多了一個 View onCreateView(View parent, String name,Context context, @NonNull AttributeSet attrs); 相比較于 Factory 的 onCreateView 而言多了一個 View 類型的 parent 參數(shù)。

稍后我們再來看這幾個屬性是在何時被設(shè)置的,讓我們回到 createViewFromTag 方法

3.2.2 LayoutInflater#createView

如果我們沒有設(shè)置 mFactory2 這些屬性,那么 tryOnCreateView 這個方法返回的 View 對象就是 null,之后我們會根據(jù) XML 標(biāo)記是否包含 "." 來決定調(diào)用不同的方法。

如果標(biāo)簽不包含 "." ,說明這是 Android 系統(tǒng)提供的 View 例如:TextView,ImageView...,這時我們會調(diào)用 onCreateView 方法,這個方法會調(diào)用一些列的重載方法,最后會調(diào)用 createView(String name, String prefix, AttributeSet attrs) 方法:

protected View onCreateView(String name, AttributeSet attrs)
        throws ClassNotFoundException {
    return createView(name, "android.view.", attrs);
}

public final View createView(String name, String prefix, AttributeSet attrs)
        throws ClassNotFoundException, InflateException {
    Context context = (Context) mConstructorArgs[0];
    if (context == null) {
        context = mContext;
    }
    return createView(context, name, prefix, attrs);
}

在 onCreateView 方法當(dāng)中,我們會調(diào)用 createView 方法,并且限定了 prefix 這個入?yún)?"android.view",緊接著我們會調(diào)用最后重載的 createView 方法:

public final View createView(@NonNull Context viewContext, @NonNull String name,
            @Nullable String prefix, @Nullable AttributeSet attrs)
            throws ClassNotFoundException, InflateException {
        Constructor<? extends View> constructor = sConstructorMap.get(name);
        if (constructor != null && !verifyClassLoader(constructor)) {
            constructor = null;
            sConstructorMap.remove(name);
        }
        Class<? extends View> clazz = null;

        try {
            if (constructor == null) {
                // Class not found in the cache, see if it's real, and try to add it
                clazz = Class.forName(prefix != null ? (prefix + name) : name, false,
                        mContext.getClassLoader()).asSubclass(View.class);
                constructor = clazz.getConstructor(mConstructorSignature);
                constructor.setAccessible(true);
                sConstructorMap.put(name, constructor);
            } else {
                // If we have a filter, apply it to cached constructor
                ...
            }
        
            try {
                final View view = constructor.newInstance(args);
                if (view instanceof ViewStub) {
                    // Use the same context when inflating ViewStub later.
                    final ViewStub viewStub = (ViewStub) view;
                    viewStub.setLayoutInflater(cloneInContext((Context) args[0]));
                }
                return view;
            } finally {
                mConstructorArgs[0] = lastContext;
            }
        } 
        ...
    }

我省略一些異常捕獲和日志代碼,可以看到我們會嘗試從緩存中獲取當(dāng)前 View 的構(gòu)造函數(shù),如果命中緩存的話,則直接通過反射創(chuàng)建 View 對象并返回,否則我們會通過入?yún)?nameprefix 來拼接 View 的全路徑類名,然后在通過反射獲取它的構(gòu)造函數(shù),放入緩存之后在創(chuàng)建 View 返回。

到這里我們可以說 LayoutInflater 是通過反射的方式將 XML 實(shí)例化成一個 View 對象,這么說沒錯,但是之前我們有提到過,在反射創(chuàng)建 View 對象之前,會經(jīng)過 mFactory 等對象的處理,接下來讓我們肯看這些“工廠“是什么時候設(shè)置的。

4 LayoutInflater#setFactory2

之前我們分析的是 AppCompatActivity#setContentView 流程:

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    }
}

在 setContentView 之前我們調(diào)用了父類的 onCreate 方法:

 @Override
 protected void onCreate(@Nullable Bundle savedInstanceState) {
     final AppCompatDelegate delegate = getDelegate();
     delegate.installViewFactory();
     delegate.onCreate(savedInstanceState);
     super.onCreate(savedInstanceState);
 }

這里可以看到我們調(diào)用了 delegate 對象的 installViewFactory 方法:

//AppCompatDelegateImpl
@Override
public void installViewFactory() {
    LayoutInflater layoutInflater = LayoutInflater.from(mContext);
    if (layoutInflater.getFactory() == null) {
        LayoutInflaterCompat.setFactory2(layoutInflater, this);
    } else {
        if (!(layoutInflater.getFactory2() instanceof AppCompatDelegateImpl)) {
            Log.i(TAG, "The Activity's LayoutInflater already has a Factory installed"
                    + " so we can not install AppCompat's");
        }
    }
}

其中 LayoutInflaterCompat.setFactory2(layoutInflater, this);對 mFactory 進(jìn)行了設(shè)置:

//LayoutInflaterCompat
class AppCompatDelegateImpl extends AppCompatDelegate
        implements MenuBuilder.Callback, LayoutInflater.Factory2{
        //......
}

public static void setFactory2(
        @NonNull LayoutInflater inflater, @NonNull LayoutInflater.Factory2 factory) {
    inflater.setFactory2(factory);
    //省略了兼容性代碼......
}

AppCompatDelegateImpl 實(shí)現(xiàn)了 Factory2 接口,我們通過 setFactory2 方法將 this 賦值給 mFactory 和 mFactory2 對象,下面我們來看 Factory2 接口在 AppCompatDelegateImpl 中的實(shí)現(xiàn):

 @Override
 public final View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
     return createView(parent, name, context, attrs);
 }
 
 public View createView(View parent, final String name, @NonNull Context context,
            @NonNull AttributeSet attrs) {
        //省略部分代碼......
        mAppCompatViewInflater = new AppCompatViewInflater();
        return mAppCompatViewInflater.createView(parent, name, context, attrs, inheritContext,
                IS_PRE_LOLLIPOP, true, VectorEnabledTintResources.shouldBeUsed()
        );
    }

可以看到在 createView 方法中,我們構(gòu)建了一個 AppCompatViewInflater 對象,將創(chuàng)建 View 的操作交給了它,接著我們看這個對象的 createView 方法:

final View createView(View parent, final String name, @NonNull Context context,
        @NonNull AttributeSet attrs, boolean inheritContext,
        boolean readAndroidTheme, boolean readAppTheme, boolean wrapContext) {
    final Context originalContext = context;
    // We can emulate Lollipop's android:theme attribute propagating down the view hierarchy
    // by using the parent's context
    if (inheritContext && parent != null) {
        context = parent.getContext();
    }
    if (readAndroidTheme || readAppTheme) {
        // We then apply the theme on the context, if specified
        context = themifyContext(context, attrs, readAndroidTheme, readAppTheme);
    }
    if (wrapContext) {
        context = TintContextWrapper.wrap(context);
    }
    View view = null;
    // We need to 'inject' our tint aware Views in place of the standard framework versions
    switch (name) {
        case "TextView":
            view = createTextView(context, attrs);
            verifyNotNull(view, name);
            break;
        case "ImageView":
            view = createImageView(context, attrs);
            verifyNotNull(view, name);
            break;
    // 省略了后續(xù)代碼...
}

protected AppCompatTextView createTextView(Context context, AttributeSet attrs) {
    return new AppCompatTextView(context, attrs);
}
  1. 可以看見,為了能夠兼容,使得 android 5.0 之前的版本能夠使用矢量圖功能,我們會對originalContext 進(jìn)行包裝,通過 TintContextWrapper 的 wrapper 方法將它包裝成 TintContextWrapper 類型
  2. 通過字符串匹配,直接使用 new 對象的方式,創(chuàng)建相對應(yīng)的 View,節(jié)省了反射帶來的開銷

由此可見,我們雖然我們在 XML 中聲明的是 TextView 類型,但是為了向前兼容,系統(tǒng)實(shí)際創(chuàng)建的是 AppCompatTextView 類型;另外通過 View#getContext 方法獲取到的類型也不一定是 Activity 類型,也有可能是 TintContextWrapper 類型。

5 LayoutInflater#setPrivateFactory

經(jīng)過上面的流程分析,我們已經(jīng)知道 mFactory 和 mFactory2 屬性是什么時候被賦值的,那么 mPrivateFactory 又是什么時候被賦值的呢?

/**
 * @hide for use by framework
 */
@UnsupportedAppUsage
public void setPrivateFactory(Factory2 factory) {
    if (mPrivateFactory == null) {
        mPrivateFactory = factory;
    } else {
        mPrivateFactory = new FactoryMerger(factory, factory, mPrivateFactory, mPrivateFactory);
    }
}

我們可以通過 LayoutInflater#setPrivateFactory 方法設(shè)置 mPrivateFactory,注釋中說這是個隱藏方法,被 framework 層使用,這里給大家一個看源碼的網(wǎng)站,就是谷歌最近剛開源的 Code Serach,我們通過這個網(wǎng)站來查看一下
setPrivateFactory 方法的引用。

-w1434

如圖,我們在 Activity attach 方法調(diào)用的時候通過 mWindow.getLayoutInflater().setPrivateFactory(this);對 mPrivateFactory 進(jìn)行設(shè)置,并且設(shè)置的值是 this,說明我們 Activity 也實(shí)現(xiàn)了 Factory2 接口。

下面我們來看 Factory2 接口在 Activity 中的實(shí)現(xiàn):

public View onCreateView(@Nullable View parent, @NonNull String name,
        @NonNull Context context, @NonNull AttributeSet attrs) {
    if (!"fragment".equals(name)) {
        return onCreateView(name, context, attrs);
    }
    return mFragments.onCreateView(parent, name, context, attrs);
}

可以看到這里對 XML 中的 fragment 標(biāo)記進(jìn)行了處理,F(xiàn)ragmentController#onCreateView 方法會對 Fragment 進(jìn)行實(shí)例化,并調(diào)用 Fragment 實(shí)現(xiàn)的 onCreateView 方法來返回 View 對象。

到此為止我們就知道了 XML 中的 fragment 標(biāo)簽是如何被處理的。

6. final

Android 系統(tǒng)通過給 LayoutInflater 設(shè)置工廠的方式,自己決定 View 的實(shí)例化,以此來實(shí)現(xiàn)向前兼容,利用 setFactory 方法我們還可以做到很多事情,比如全局字體的替換,給特定的 View 設(shè)置特定的背景...

同時我也只對最基礎(chǔ)的流程進(jìn)行分析,里面對 merge、include、viewStub 等標(biāo)簽的處理并沒有展開,其實(shí)這些標(biāo)簽也只是遞歸的進(jìn)行 View 的創(chuàng)建并添加進(jìn)容器而已。

參考鏈接:

  1. https://juejin.im/post/5dd499a6f265da0bf21126cc
  2. https://blog.csdn.net/mq2553299/article/details/99737681
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容