• 【转载】FloatingActionButton源码解析


    原文地址:https://github.com/Rowandjj/my_awesome_blog/blob/master/fab_anlysis/README.md

    loatingActionButton源码解析

    背景

    FloatingActionButton(下文以fab代替)是android support design组件库中提供的一个视图控件,是material design设计中fab的官方实现。

    此控件的官方介绍如下:

    Floating action buttons are used for a promoted action. They are distinguished by a circled icon floating above the UI and have motion behaviors that include morphing, launching, and a transferring anchor point.

    关于该控件的设计规范及使用场景请参考文档:

    http://www.google.com/design/spec/components/buttons-floating-action-button.html#

    如果你还不了解design组件库,请参考官方博客:

    http://android-developers.blogspot.hk/2015/05/android-design-support-library.html

    开始

    源码版本:23.3.0

    类图

    fab间接继承自ImageViewImageButtonImageView的子类),因而拥有ImageView的大部分特性。但是其内部还是做了很多定制,我们一一来看。

    1. fab的自定义属性、背景着色相关

    从构造器开始:

    public FloatingActionButton(Context context, AttributeSet attrs, int defStyleAttr) {
            super(context, attrs, defStyleAttr);
             //检查是否使用Theme.Appcompat主题
            ThemeUtils.checkAppCompatTheme(context);
            //拿到自定义属性并赋值
            TypedArray a = context.obtainStyledAttributes(attrs,
                    R.styleable.FloatingActionButton, defStyleAttr,
                    R.style.Widget_Design_FloatingActionButton);
           ...
            a.recycle();
           
             
            final int maxImageSize = (int) getResources().getDimension(R.dimen.design_fab_image_size);
            mImagePadding = (getSizeDimension() - maxImageSize) / 2;
            
            //背景着色
            getImpl().setBackgroundDrawable(mBackgroundTint, mBackgroundTintMode,
                    mRippleColor, mBorderWidth);
          //绘制阴影
            getImpl().setElevation(elevation);
            ...
        }

    构造器中主要是拿到用户设置的自定义属性,比如着色、波纹颜色、大小等等,一共有以下几个属性可以定义。

    <declare-styleable name="FloatingActionButton">
    <attr name="backgroundTint"/>
    <attr name="backgroundTintMode"/>
    <attr format="color" name="rippleColor"/>
    <attr name="fabSize">
        <enum name="normal" value="0"/>
        <enum name="mini" value="1"/>
    </attr>
    <attr name="elevation"/>
    <attr format="dimension" name="pressedTranslationZ"/>
    <attr format="dimension" name="borderWidth"/>
    <attr format="boolean" name="useCompatPadding"/>
    </declare-styleable>

    属性的默认值定义如下:

     <style name="Widget.Design.FloatingActionButton" parent="android:Widget">
     
            <item name="android:background">@drawable/design_fab_background</item>
            <item name="backgroundTint">?attr/colorAccent</item>
            <item name="fabSize">normal</item>
            <item name="elevation">@dimen/design_fab_elevation</item>
            <item name="pressedTranslationZ">@dimen/design_fab_translation_z_pressed</item>
            <item name="rippleColor">?attr/colorControlHighlight</item>
            <item name="borderWidth">@dimen/design_fab_border_width</item>
            
        </style>

    需要注意的是android:background属性,这里指定了background为design_fab_background,并且不允许改变:

     @Override
        public void setBackgroundDrawable(Drawable background) {
            Log.i(LOG_TAG, "Setting a custom background is not supported.");
        }

    那么我们来看下这个background长啥样:

    <shape xmlns:android="http://schemas.android.com/apk/res/android"
            android:shape="oval">
        <solid android:color="@android:color/white" />
    </shape>

    很显然,fab的形状固定为圆形都是因为这个background。那么这里指定了背景色为白色,那是不是fab只能是白色背景呢?当然不是,还有我们牛逼的backgroundTint(即背景着色),tint是android 5.x引进的一个新特性,可以动态地给drawable资源着色,其原理就是通过给控件设置colorFilter:

    drawable.java

    public void setColorFilter(@ColorInt int color, @NonNull PorterDuff.Mode mode) {
            setColorFilter(new PorterDuffColorFilter(color, mode));
        }

    默认的着色模式为SRC_IN(取交集、显示上层,故底层白色会被忽略):

    static final PorterDuff.Mode DEFAULT_TINT_MODE = PorterDuff.Mode.SRC_IN;

    在fab构造的时候,会指定着色为?attr/colorAccent,即当前主题的colorAccent属性值。 然后执行如下代码,进行着色。

    getImpl().setBackgroundDrawable(mBackgroundTint, mBackgroundTintMode,
                    mRippleColor, mBorderWidth);

    因为不同版本间的实现略有不同,所以这里会根据不同版本创建不同的FloatingActionButtonImpl实现类:

    private FloatingActionButtonImpl createImpl() {
            final int sdk = Build.VERSION.SDK_INT;
            if (sdk >= 21) {
                return new FloatingActionButtonLollipop(this, new ShadowDelegateImpl());
            } else if (sdk >= 14) {
                return new FloatingActionButtonIcs(this, new ShadowDelegateImpl());
            } else {
                return new FloatingActionButtonEclairMr1(this, new ShadowDelegateImpl());
            }
        }

    以5.x为例,其setBackgroundDrawable实现代码如下:

    先创建着色的背景drawable。

     GradientDrawable createShapeDrawable() {
            GradientDrawable d = new GradientDrawable();
            d.setShape(GradientDrawable.OVAL);
            d.setColor(Color.WHITE);
            return d;
        }

    再对此drawable设置tint:

    @Override
        void setBackgroundDrawable(ColorStateList backgroundTint,
                PorterDuff.Mode backgroundTintMode, int rippleColor, int borderWidth) {
            // Now we need to tint the shape background with the tint
            mShapeDrawable = DrawableCompat.wrap(createShapeDrawable());
            
            //着色,这里会其实就是设置了下colorFilter
            DrawableCompat.setTintList(mShapeDrawable, backgroundTint);
            
            if (backgroundTintMode != null) {
                DrawableCompat.setTintMode(mShapeDrawable, backgroundTintMode);
            }
    
            final Drawable rippleContent;
            if (borderWidth > 0) {
                mBorderDrawable = createBorderDrawable(borderWidth, backgroundTint);
                rippleContent = new LayerDrawable(new Drawable[]{mBorderDrawable, mShapeDrawable});
            } else {
                mBorderDrawable = null;
                rippleContent = mShapeDrawable;
            }
    
            mRippleDrawable = new RippleDrawable(ColorStateList.valueOf(rippleColor),
                    rippleContent, null);
    
            mContentBackground = mRippleDrawable;
    
            mShadowViewDelegate.setBackgroundDrawable(mRippleDrawable);
        }

    经过着色,fab就呈现出我们想要的颜色啦。

    2. fab的大小

    再来看fab的大小,fab有两种大小,一种是NORMAL,一种是MINI,实际大小分别是56dp和40dp,其定义可以在design库的values.xml中看到。

    fab如何控制控件大小只有这两种规格呢(这样说不准确,事实上你可以通过设置fab的layout_width/layout_height指定为任意大小,但是我们最好按照MD规范来)?必然是通过复写onMeasure啦:

     @Override
        protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
            //我们希望的大小
            final int preferredSize = getSizeDimension();
             //最终测量的大小
            final int w = resolveAdjustedSize(preferredSize, widthMeasureSpec);
            final int h = resolveAdjustedSize(preferredSize, heightMeasureSpec);
    
            //取小值,保证最后绘制的是圆形
            final int d = Math.min(w, h);
    
            // We add the shadow's padding to the measured dimension
            setMeasuredDimension(
                    d + mShadowPadding.left + mShadowPadding.right,
                    d + mShadowPadding.top + mShadowPadding.bottom);
        }

    其中getSizeDimension方法计算出来的是我们期望的大小:

    final int getSizeDimension() {
            switch (mSize) {
                case SIZE_MINI:
                    return getResources().getDimensionPixelSize(R.dimen.design_fab_size_mini);//40dp
                case SIZE_NORMAL:
                default:
                    return getResources().getDimensionPixelSize(R.dimen.design_fab_size_normal);//56dp
           
           }
       }

    但是最终的值还是得看我们设置的LayoutParams。关于控件测量相关内容不在此文介绍范围内,大家可以自行google。

    3.fab的动画

    fab还支持fab以动画的方式显现/隐藏,通常和AppBarLayout一起使用,可以通过hide()/show()两个方法控制。

    那么动画是如何实现的呢:

    private void show(OnVisibilityChangedListener listener, boolean fromUser) {
            getImpl().show(wrapOnVisibilityChangedListener(listener), fromUser);
        }
        
    private void hide(@Nullable OnVisibilityChangedListener listener, boolean fromUser) {
        getImpl().hide(wrapOnVisibilityChangedListener(listener), fromUser);
    }

    这里因为要兼容不同版本,所以具体实现也交给了不同的fab实现类。3.x之后很好办,直接使用属性动画,如果是3.x之前的话,那么只能使用传统的Animation了

    hide()为例,使用属性动画较为简单,直接使用View#animate()即可链式调用。

    @Override
        void hide(@Nullable final InternalVisibilityChangedListener listener, final boolean fromUser) {
            if (mIsHiding || mView.getVisibility() != View.VISIBLE) {
                // A hide animation is in progress, or we're already hidden. Skip the call
                if (listener != null) {
                    listener.onHidden();
                }
                return;
            }
    
            if (!ViewCompat.isLaidOut(mView) || mView.isInEditMode()) {
                // If the view isn't laid out, or we're in the editor, don't run the animation
                mView.internalSetVisibility(View.GONE, fromUser);
                if (listener != null) {
                    listener.onHidden();
                }
            } else {
                mView.animate().cancel();
                mView.animate()
                        .scaleX(0f)
                        .scaleY(0f)
                        .alpha(0f)
                        .setDuration(SHOW_HIDE_ANIM_DURATION)
                        .setInterpolator(AnimationUtils.FAST_OUT_LINEAR_IN_INTERPOLATOR)
                        .setListener(new AnimatorListenerAdapter() {
                            private boolean mCancelled;
    
                            @Override
                            public void onAnimationStart(Animator animation) {
                                mIsHiding = true;
                                mCancelled = false;
                                mView.internalSetVisibility(View.VISIBLE, fromUser);
                            }
    
                            @Override
                            public void onAnimationCancel(Animator animation) {
                                mIsHiding = false;
                                mCancelled = true;
                            }
    
                            @Override
                            public void onAnimationEnd(Animator animation) {
                                mIsHiding = false;
                                if (!mCancelled) {
                                    mView.internalSetVisibility(View.GONE, fromUser);
                                    if (listener != null) {
                                        listener.onHidden();
                                    }
                                }
                            }
                        });
            }
        }

    如果使用传统动画的话,则先在xml中定义好动画,然后构造Animation实例,启动动画。

    @Override
        void hide(@Nullable final InternalVisibilityChangedListener listener, final boolean fromUser) {
            if (mIsHiding || mView.getVisibility() != View.VISIBLE) {
                // A hide animation is in progress, or we're already hidden. Skip the call
                if (listener != null) {
                    listener.onHidden();
                }
                return;
            }
    
            Animation anim = android.view.animation.AnimationUtils.loadAnimation(
                    mView.getContext(), R.anim.design_fab_out);
            anim.setInterpolator(AnimationUtils.FAST_OUT_LINEAR_IN_INTERPOLATOR);
            anim.setDuration(SHOW_HIDE_ANIM_DURATION);
            anim.setAnimationListener(new AnimationUtils.AnimationListenerAdapter() {
                @Override
                public void onAnimationStart(Animation animation) {
                    mIsHiding = true;
                }
    
                @Override
                public void onAnimationEnd(Animation animation) {
                    mIsHiding = false;
                    mView.internalSetVisibility(View.GONE, fromUser);
                    if (listener != null) {
                        listener.onHidden();
                    }
                }
            });
            mView.startAnimation(anim);
        }

    4. fab与CoordinatorLayout的交互

    这块内容因为与CoordinatorLayout/CoordinatorLayout#Behavior有很大关联,如果不熟悉,请先google相关资料。本文假设读者对这块内容已经有一定理解。

    fab并不直接与CoordinatorLayout联系,而是通过CoordinatorLayout#Behavior作为桥梁。CoordinatorLayout类通过CoordinatorLayout#Behavior可以间接控制其直系子View的行为,能控制什么行为?View测量、布局、touch事件拦截、监听、NestedScroll等等。是不是很屌。

    fab内部实现了CoordinatorLayout#Behavior抽象类。该抽象类有如下接口:

    public static abstract class Behavior<V extends View> {
    
            ...
       
            public boolean onInterceptTouchEvent(CoordinatorLayout parent, V child, MotionEvent ev) {
                return false;
            }
    
            public boolean onTouchEvent(CoordinatorLayout parent, V child, MotionEvent ev) {
                return false;
            }
    
           ...
            /**
             * Determine whether the supplied child view has another specific sibling view as a
             * layout dependency.
             *
             * <p>This method will be called at least once in response to a layout request. If it
             * returns true for a given child and dependency view pair, the parent CoordinatorLayout
             * will:</p>
             * <ol>
             *     <li>Always lay out this child after the dependent child is laid out, regardless
             *     of child order.</li>
             *     <li>Call {@link #onDependentViewChanged} when the dependency view's layout or
             *     position changes.</li>
             * </ol>
             */
            public boolean layoutDependsOn(CoordinatorLayout parent, V child, View dependency) {
                return false;
            }
    
            /**
             * Respond to a change in a child's dependent view
             *
             * <p>This method is called whenever a dependent view changes in size or position outside
             * of the standard layout flow. A Behavior may use this method to appropriately update
             * the child view in response.</p>
             *
             * <p>A view's dependency is determined by
             * {@link #layoutDependsOn(CoordinatorLayout, android.view.View, android.view.View)} or
             * if {@code child} has set another view as it's anchor.</p>
             *
             * <p>Note that if a Behavior changes the layout of a child via this method, it should
             * also be able to reconstruct the correct position in
             * {@link #onLayoutChild(CoordinatorLayout, android.view.View, int) onLayoutChild}.
             * <code>onDependentViewChanged</code> will not be called during normal layout since
             * the layout of each child view will always happen in dependency order.</p>
             *
             * <p>If the Behavior changes the child view's size or position, it should return true.
             * The default implementation returns false.</p>
             *
             */
            public boolean onDependentViewChanged(CoordinatorLayout parent, V child, View dependency) {
                return false;
            }
    
                ...
    
        
    
            /**
             * Called when the parent CoordinatorLayout is about the lay out the given child view.
             *
             * <p>This method can be used to perform custom or modified layout of a child view
             * in place of the default child layout behavior. The Behavior's implementation can
             * delegate to the standard CoordinatorLayout measurement behavior by calling
             * {@link CoordinatorLayout#onLayoutChild(android.view.View, int)
             * parent.onLayoutChild}.</p>
             *
             * <p>If a Behavior implements
             * {@link #onDependentViewChanged(CoordinatorLayout, android.view.View, android.view.View)}
             * to change the position of a view in response to a dependent view changing, it
             * should also implement <code>onLayoutChild</code> in such a way that respects those
             * dependent views. <code>onLayoutChild</code> will always be called for a dependent view
             * <em>after</em> its dependency has been laid out.</p>
             *
             */
            public boolean onLayoutChild(CoordinatorLayout parent, V child, int layoutDirection) {
                return false;
            }
     
          ...
         
            public void onNestedScroll(CoordinatorLayout coordinatorLayout, V child, View target,
                    int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) {
                // Do nothing
            }
    
        
        }

    看到这个抽象类,有两点需要注意:

    1. 此抽象类并无抽象方法,也即子类可选择任何想复写的方法进行复写。
    2. 此抽象类接受一个泛型。该泛型需要是View的子类。

    fab实现此抽象类:

    public static class Behavior extends CoordinatorLayout.Behavior<FloatingActionButton> {}

    有选择性地实现了三个方法:

    public boolean layoutDependsOn(CoordinatorLayout parent,
                    FloatingActionButton child, View dependency);
                    
    public boolean onDependentViewChanged(CoordinatorLayout parent, FloatingActionButton child,
                    View dependency);
    
     public boolean onLayoutChild(CoordinatorLayout parent, FloatingActionButton child,
                    int layoutDirection);            

    fab为啥要实现Behavior?主要是为了配合其他控件完成一些复杂的交互,比较经典的像这个:

    fab动画效果

    fab需要在snackBar弹出的时候自动向上平移,这就得知道SnackBar的状态了,实现Behavior让fab有机会监听到其他CoordinatorLayout子View的状态,并根据状态更新自己。

    复写layoutDependsOn方法可以告诉CoordinatorLayout我对哪个View感兴趣,

    这里当然是SnackBar了。(注意哦,SnackBar最终展现的是SnackbarLayout,SnackBar本身并不是View)

    private static final boolean SNACKBAR_BEHAVIOR_ENABLED = Build.VERSION.SDK_INT >= 11;
    
     @Override
            public boolean layoutDependsOn(CoordinatorLayout parent,
                    FloatingActionButton child, View dependency) {
                // We're dependent on all SnackbarLayouts (if enabled)
                return SNACKBAR_BEHAVIOR_ENABLED && dependency instanceof Snackbar.SnackbarLayout;
            }

    为什么API LEVEL要大于11呢?因为google偷懒想直接使用属性动画。

    前面告诉了CoordinatorLayoutfab对SnackBar比较感兴趣,那么当SnackBar状态改变的时候,CoordinatorLayout就会通过onDependentViewChanged回调通知fab:

    fab就可以更新自己的UI拉(这里当然是平移喽):

    @Override
            public boolean onDependentViewChanged(CoordinatorLayout parent, FloatingActionButton child,
                    View dependency) {
                if (dependency instanceof Snackbar.SnackbarLayout) {
                    updateFabTranslationForSnackbar(parent, child, dependency);
                } else if (dependency instanceof AppBarLayout) {
                    // If we're depending on an AppBarLayout we will show/hide it automatically
                    // if the FAB is anchored to the AppBarLayout
                    updateFabVisibility(parent, (AppBarLayout) dependency, child);
                }
                return false;
            }

    如果是SnackBar状态变化了,那么fab就会根据情况进行平移:

    private void updateFabTranslationForSnackbar(CoordinatorLayout parent,
                    final FloatingActionButton fab, View snackbar) {
                final float targetTransY = getFabTranslationYForSnackbar(parent, fab);
                if (mFabTranslationY == targetTransY) {
                    // We're already at (or currently animating to) the target value, return...
                    return;
                }
    
                final float currentTransY = ViewCompat.getTranslationY(fab);
    
                // Make sure that any current animation is cancelled
                if (mFabTranslationYAnimator != null && mFabTranslationYAnimator.isRunning()) {
                    mFabTranslationYAnimator.cancel();
                }
    
                if (fab.isShown()
                        && Math.abs(currentTransY - targetTransY) > (fab.getHeight() * 0.667f)) {
                    // If the FAB will be travelling by more than 2/3 of it's height, let's animate
                    // it instead
                    if (mFabTranslationYAnimator == null) {
                        mFabTranslationYAnimator = ViewUtils.createAnimator();
                        mFabTranslationYAnimator.setInterpolator(
                                AnimationUtils.FAST_OUT_SLOW_IN_INTERPOLATOR);
                        mFabTranslationYAnimator.setUpdateListener(
                                new ValueAnimatorCompat.AnimatorUpdateListener() {
                                    @Override
                                    public void onAnimationUpdate(ValueAnimatorCompat animator) {
                                        ViewCompat.setTranslationY(fab,
                                                animator.getAnimatedFloatValue());
                                    }
                                });
                    }
                    mFabTranslationYAnimator.setFloatValues(currentTransY, targetTransY);
                    mFabTranslationYAnimator.start();
                } else {
                    // Now update the translation Y
                    ViewCompat.setTranslationY(fab, targetTransY);
                }
    
                mFabTranslationY = targetTransY;
            }

    代码里的注释很多,我就不解释了。

    前面说到AppBarLayout和fab一起使用可以完成另一个效果,即AppBarLayout伸缩时,fab也可以以动画的形式显现、隐藏,其实现如下:

    private boolean updateFabVisibility(CoordinatorLayout parent,
                    AppBarLayout appBarLayout, FloatingActionButton child) {
                final CoordinatorLayout.LayoutParams lp =
                        (CoordinatorLayout.LayoutParams) child.getLayoutParams();
                //注意到我们必须为fab指定layout_anchor为appBarLayout                    
                if (lp.getAnchorId() != appBarLayout.getId()) {
                    // The anchor ID doesn't match the dependency, so we won't automatically
                    // show/hide the FAB
                    return false;
                }
    
                if (child.getUserSetVisibility() != VISIBLE) {
                    // The view isn't set to be visible so skip changing it's visibility
                    return false;
                }
    
                if (mTmpRect == null) {
                    mTmpRect = new Rect();
                }
    
                // First, let's get the visible rect of the dependency
                final Rect rect = mTmpRect;
                ViewGroupUtils.getDescendantRect(parent, appBarLayout, rect);
    
                if (rect.bottom <= appBarLayout.getMinimumHeightForVisibleOverlappingContent()) {
                    // If the anchor's bottom is below the seam, we'll animate our FAB out
                    child.hide(null, false);
                } else {
                    // Else, we'll animate our FAB back in
                    child.show(null, false);
                }
                return true;
            }

    除此之外,fab#Behavior还实现了onLayoutChild,主要是为了根据AppBarLayout的当前状态来判断自己是否需要隐藏。

    @Override
            public boolean onLayoutChild(CoordinatorLayout parent, FloatingActionButton child,
                    int layoutDirection) {
                // First, lets make sure that the visibility of the FAB is consistent
                final List<View> dependencies = parent.getDependencies(child);
                for (int i = 0, count = dependencies.size(); i < count; i++) {
                    final View dependency = dependencies.get(i);
                    if (dependency instanceof AppBarLayout
                            && updateFabVisibility(parent, (AppBarLayout) dependency, child)) {
                        break;
                    }
                }
                // Now let the CoordinatorLayout lay out the FAB
                parent.onLayoutChild(child, layoutDirection);
                // Now offset it if needed
                offsetIfNeeded(parent, child);
                return true;
            }

    此方法会在CoordinatorLayout对孩子布局的时候进行调用(即CoordinatorLayout#onLayout),CoordinatorLayout会检查所有的直系孩子,是否设置了Behavior,如果设置了,那么就执行其onLayoutChild方法:

    CoordinatorLayout#onLayout

    @Override
        protected void onLayout(boolean changed, int l, int t, int r, int b) {
            final int layoutDirection = ViewCompat.getLayoutDirection(this);
            final int childCount = mDependencySortedChildren.size();
            for (int i = 0; i < childCount; i++) {
                final View child = mDependencySortedChildren.get(i);
                final LayoutParams lp = (LayoutParams) child.getLayoutParams();
                final Behavior behavior = lp.getBehavior();
    
                if (behavior == null || !behavior.onLayoutChild(this, child, layoutDirection)) {
                    onLayoutChild(child, layoutDirection);
                }
            }
        }

    如果该Behavior实现了OnLayoutChild,并且返回了true,那么将不会执行CoordinatorLayout #onLayoutChild,否则执行默认的布局方案。 最后一点,这里的Behavior如何生效的呢?通过注解:

    @CoordinatorLayout.DefaultBehavior(FloatingActionButton.Behavior.class)
    public class FloatingActionButton extends VisibilityAwareImageButton {

    CoordinatorLayout在解析孩子的LayoutParams时,会check有无注解:

    LayoutParams getResolvedLayoutParams(View child) {
            final LayoutParams result = (LayoutParams) child.getLayoutParams();
            if (!result.mBehaviorResolved) {
                Class<?> childClass = child.getClass();
                DefaultBehavior defaultBehavior = null;
                while (childClass != null &&
                        (defaultBehavior = childClass.getAnnotation(DefaultBehavior.class)) == null) {
                    childClass = childClass.getSuperclass();
                }
                if (defaultBehavior != null) {
                    try {
                        result.setBehavior(defaultBehavior.value().newInstance());
                    } catch (Exception e) {
                        Log.e(TAG, "Default behavior class " + defaultBehavior.value().getName() +
                                " could not be instantiated. Did you forget a default constructor?", e);
                    }
                }
                result.mBehaviorResolved = true;
            }
            return result;
        }

    至此fab解析完毕,谢谢观看!

  • 相关阅读:
    UITableViewCell 获取当前位置
    iOS图片拉伸
    TCP/IP基础
    AFNetworking报错"_UTTypeCopyPreferredTagWithClass", referenced from: _AFContentTypeForPathExtens
    iOS 后台处理
    统计iOS项目的总代码行数的方法
    iOS自定义model排序
    iOS开发 适配iOS10
    中文 iOS/Mac 开发博客列表
    C#--静态构造函数
  • 原文地址:https://www.cnblogs.com/anni-qianqian/p/6761302.html
Copyright © 2020-2023  润新知