源码分析自定义ViewGroup中Fragment无法显示的问题

一、背景

昨天接到同学的一个问题:用了别人的自定义侧滑菜单控件,这个控件继承自ViewGroup,想通过左侧菜单里的列表,更改右侧界面显示的内容,内容通过Fragment来显示。问题来了:

  1. 点击列表时,replace一个新的Fragment,无法显示
  2. 在onCreate()中直接replace,可以显示

二、填坑过程

这里完全是个自嘲过程,可以直接跳过。

因为调试过几次后,觉得应该从源码找原因,简单地看了下,没找到突破口,又继续调试。最后原因还是通过源码找到的,但这个教训还是得记录下来。

他的代码看上去没有正常,找bug的思路有时也挺逗的,毫无保留地怀疑自己的一切逻辑,O(∩_∩)O哈哈~。主要从这几方面分析:

  1. 首先,看时候点击事件是否抵达。结果:正常
  2. 检查Fragment生命周期,是否启动,即是否调用了onCreateView()。结果:已启动
  3. 检查Fragment生命周期,是否因某个原因关闭了,即是否调用了onDestroyView()。结果:未关闭
  4. 也可以再去检查其它生命周期:onAttach()、onCreate()、onActivityCreated()、onStart()、onResume(),也肯定一样:正常
  5. 这里,就发现:Fragment正常创建出来了,但就是没显示出来
  6. 甚至怀疑其他地方动过手脚,就在自定义ViewGroup外面,创建一个类似的功能,一个button点击后,显示一个Fragment。结果:正常
  7. 说明就是自定义ViewGroup内部的问题。查看代码,并没有修改过contentView(用于显示Fragment的FrameLayout)的内容
  8. 难道没有重新绘制?在ViewGroup里添加一个TextView,button点击后,除了显示Fragment,同时把TextView的内容修改掉,使用计数器,这样显示的内容,每次都不一样。结果:TextView的内容正常修改了,但Fragment依旧没显示
  9. 中途,还有天马行空、不合逻辑地怀疑:构造方法少了一个三个参数的,毫无疑问,补上无果,否则早就报错了;宽、高都是以屏幕为标准,改成固定值,结果:还是无效。
  10. 同学说点击之后就没有,而里面重写了onInterceptTouchEvent()、onTouchEvent(),是否跟这有关。成功引导我去查了一遍,甚至把它两干掉。结果:无效
  11. 最后代码抽丝剥茧,只剩下三个构造、onMeasure()、onLayout()。依旧不行
  12. 不自定义ViewGroup的话,直接在xml布局中使用FrameLayout或LinearLayout,肯定可以。就把继承改成FrameLayout,onMeasure()和onLayout()去掉。结果:正常
  13. 继承FrameLayout,把原来的onMeasure()与onLayout()保留。结果:不显示
  14. 最后,把onMeasure()与onLayout()里,两个为了性能而加的开关给关掉。结果:正常

强迫症的我,最终还是把问题给解决了。但,原因不知道,也肯定难受,回到家又分析了一波Fragment的源码(自己看比较吃力,后来还是跟着后面的参考博客来分析的),主要查看replace()和commit()后做了哪些事情。

三、原因分析

3.1 问题复原

找到了问题,逆向分析原因。知道原因后,再来正向具体分析,就会一目了然。用一个简单的demo来复现:

/**
 * 自定义ViewGroup
 */
public class TestViewGroup extends ViewGroup {
    private boolean isMeasured;
    public TestViewGroup(Context context) {
        super(context);
    }
    public TestViewGroup(Context context, AttributeSet attrs) {
        super(context, attrs);
    }
    public TestViewGroup(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        logD("onMeasure: width.mode=%d, width.size=%d, height.mode=%d, height.size=%d",
                (widthMeasureSpec & 3 << 30) >> 30, widthMeasureSpec & 0x3FFF,
                (heightMeasureSpec & 3 << 30) >> 30, heightMeasureSpec & 0x3FFF
        );
        if (!isMeasured) {
            final int count = getChildCount();
            for (int i = 0; i < count; i++) {
                View child = getChildAt(i);
                int childW = child.getLayoutParams().width;
                int childWidthSpec = MeasureSpec.makeMeasureSpec(childW, MeasureSpec.getMode(widthMeasureSpec));
                child.measure(childWidthSpec, heightMeasureSpec);
            }
            isMeasured = true;
        }
        setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), MeasureSpec.getSize(heightMeasureSpec));
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        logD("onLayout: changed=%b, l=%d, t=%d, r=%d, b=%d",
                changed, l, t, r, b);

        if (!changed) {
            // 全部水平摆放
            final int count = getChildCount();
            int wOffset = 0;
            int w, h;
            for (int i = 0; i < count; i++) {
                View child = getChildAt(i);
                w = child.getMeasuredWidth();
                h = child.getMeasuredHeight();
                child.layout(wOffset, 0, wOffset + w, h);
                wOffset += w;
            }
            isLayouted = true;
        }
    }
}

activity_main.xml:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:padding="18dp"
    tools:context=".MainActivity">

    <com.zjun.demo.gradationview.TestViewGroup
        android:layout_width="match_parent"
        android:layout_height="100dp"
        android:layout_marginBottom="8dp"
        android:background="#c5c6c7">

        <TextView
            android:id="@+id/tv_hello"
            android:layout_width="80dp"
            android:layout_height="match_parent"
            android:text="hello"
            android:background="#cac9aa"/>

        <FrameLayout
            android:id="@+id/fl_content"
            android:layout_width="match_parent"
            android:layout_height="100dp"
            android:background="#dbbfb2"/>

    </com.zjun.demo.gradationview.TestViewGroup>

    <Button
        android:id="@+id/btn_replace"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginBottom="40dp"
        android:text="replace"
        android:textAllCaps="false"
        android:onClick="onClick"
        />

</LinearLayout>

MainActivity.java

// 省略其它的代码
public void onClick(View view) {
    switch (view.getId()) {
        case R.id.btn_replace:
            getSupportFragmentManager().beginTransaction().replace(R.id.fl_content, new TestFragment()).commit();
            break;
        default: break;
    }
}

UI效果:
这里写图片描述

点击按钮replace后,后面那块区域没有任何反应。通过log日志,可以看到每次点击,都会有日志输出(当前手机系统是4.4.2,而另一个6.0的并没有打印,应该是对onMeasure和onLayout做了优化处理):
这里写图片描述

3.2 源码分析

我们都知道,只有布局变动的情况下,才会重新测量。Fragment的replace()会引起布局的变动吗?不用想就知道肯定会(当时肯定想了),不然Fragment里的界面怎么能展示出来。现在就从源码找找
getSupportFragmentManager().beginTransaction().replace(R.id.fl_content, new TestFragment()).commit();
这句代码,具体做了哪些事情,从而让界面变化的。这里仅做快速简单分析,具体参考底下的博客。另外给这作者点个赞,首次看到源码上面带上了类名的博客,这样便于查看与理解,学习了。

以下源码版本:27.1.1

3.2.1 getSupportFragmentManager()

getSupportFragmentManager()得到的是什么?

// FragmentActvity:
public FragmentManager getSupportFragmentManager() {
    return mFragments.getSupportFragmentManager();
}

// FragmentController:
public FragmentManager getSupportFragmentManager() {
    return mHost.getFragmentManagerImpl();
}

// FragmentHostCallback:
FragmentManagerImpl getFragmentManagerImpl() {
    return mFragmentManager;
}

// FragmentHostCallback:
final FragmentManagerImpl mFragmentManager = new FragmentManagerImpl();

// 类FragmentManagerImpl在FragmentManager.java文件内,但不是内部类,而是同级:
final class FragmentManagerImpl extends FragmentManager implements LayoutInflater.Factory2

结论:getSupportFragmentManager()得到的是一个FragmentManagerImpl对象

3.2.2 beginTransaction()

再看beginTransaction()又是怎么开启事务的:

// FragmentManager:这是抽象类里的抽象方法:
public abstract FragmentTransaction beginTransaction();

// FragmentManagerImpl:由这个实现类来实现此方法:
@Override
public FragmentTransaction beginTransaction() {
    return new BackStackRecord(this);
}

// BackStackRecord:
final class BackStackRecord extends FragmentTransaction implements
        FragmentManager.BackStackEntry, FragmentManagerImpl.OpGenerator{
    public BackStackRecord(FragmentManagerImpl manager) {
        mManager = manager;
    }
}

所以,beginTransaction()是获取到了一个BackStackRecord对象

3.2.3 replace()

replace()是如何把我们xml里的R.id.fl_content替换成fragment的?

// BackStackRecord:以下代码都是在这个类中,一些非核心的代码都将省略
@Override
public FragmentTransaction replace(int containerViewId, Fragment fragment) {
    return replace(containerViewId, fragment, null);
}

@Override
public FragmentTransaction replace(int containerViewId, Fragment fragment, String tag) {
    // 注意这个操作命令:OP_REPLACE
    doAddOp(containerViewId, fragment, tag, OP_REPLACE);
    return this;
}

/**
 * 添加操作前的准备工作
 */
private void doAddOp(int containerViewId, Fragment fragment, String tag, int opcmd) {
    if (containerViewId != 0) {
        ...
        // 同一个Fragment,不能同时添加到两个不一样的containerViewId中
        if (fragment.mFragmentId != 0 && fragment.mFragmentId != containerViewId) {
            throw new IllegalStateException("Can't change container ID of fragment "
                    + fragment + ": was " + fragment.mFragmentId
                    + " now " + containerViewId);
        }
        fragment.mContainerId = fragment.mFragmentId = containerViewId;
    }

    addOp(new Op(opcmd, fragment));
}

/**
 * 添加操作
 */
void addOp(Op op) {
    mOps.add(op);
    op.enterAnim = mEnterAnim;
    op.exitAnim = mExitAnim;
    op.popEnterAnim = mPopEnterAnim;
    op.popExitAnim = mPopExitAnim;
}

// mOps是个ArrayList集合
ArrayList<Op> mOps = new ArrayList<>()

/**
 * Op是一个静态内部类,用于存放待执行的操作
 */
static final class Op {
    int cmd;
    Fragment fragment;
    // 进出动画资源id
    int enterAnim;
    int exitAnim;
    // 再次进出的动画资源id。popEnterAnim的解释:An animation or animator resource ID used for the enter animation on the view of the fragment being readded or reattached caused by
    int popEnterAnim;
    int popExitAnim;

    Op() {
    }

    Op(int cmd, Fragment fragment) {
        this.cmd = cmd;
        this.fragment = fragment;
    }
}

OK,到这,可以看到replace()就是添加了一个要待执行的替换操作Op,里面保存了操作命令、要替换的Fragment、和进出动画的信息。自定义进出动画方式:setCustomAnimations()

replace就这样没了?没错,好戏在后头呢

3.2.4 commit()
// BackStateRecord:以下代码都在此类中
@Override
public int commit() {
    return commitInternal(false);
}

int commitInternal(boolean allowStateLoss) {
    mManager.enqueueAction(this, allowStateLoss);
}
/**
 * FragmentManagerImpl:以下代码都在此类中
 * 把当前对象加入准备执行的队列
 */
public void enqueueAction(OpGenerator action, boolean allowStateLoss) {
    ...
    scheduleCommit();
}

/**
 * 第一个重点来了:调度执行,通过Handler来执行
 */
private void scheduleCommit() {
    synchronized (this) {
        boolean postponeReady =
                mPostponedTransactions != null && !mPostponedTransactions.isEmpty();
        boolean pendingReady = mPendingActions != null && mPendingActions.size() == 1;
        if (postponeReady || pendingReady) {
            mHost.getHandler().removeCallbacks(mExecCommit);
            mHost.getHandler().post(mExecCommit);
        }
    }
}

/**
 * Hanlder.post()里面只能是Runnable
 */
Runnable mExecCommit = new Runnable() {
    @Override
    public void run() {
        execPendingActions();
    }
};

/**
 * 注意:这里开始跟参考博客不一样
 * Only call from main thread!
 */
public boolean execPendingActions() {
    ...
    removeRedundantOperationsAndExecute(mTmpRecords, mTmpIsPop);
    ...
    doPendingDeferredStart();
}

先分析removeRedundantOperationsAndExecute(mTmpRecords, mTmpIsPop);

/**
 * FragmentManagerImpl:以下代码都在此类中
 * 移除mTmpRecords(ArrayList<BackStackRecord>)里多余的操作,并执行这些操作
 */
private void removeRedundantOperationsAndExecute(ArrayList<BackStackRecord> records, ArrayList<Boolean> isRecordPop){
    executeOpsTogether(records, isRecordPop, startIndex, recordNum);
}

/**
 * 一起执行操作
 */
private void executeOpsTogether(ArrayList<BackStackRecord> records, ArrayList<Boolean> isRecordPop, int startIndex, int endIndex) {
    ...
    record.expandOps(mTmpAddedFragments, oldPrimaryNav);
}

/**
 * BackStackRecord: 
 * 第二个重点:这里是OP_REPLACE命令唯一被处理的地方,但没看懂,个人觉得在这里主要保证了replace的唯一性
 */
Fragment expandOps(ArrayList<Fragment> added, Fragment oldPrimaryNav) {
            switch (op.cmd) {
                case OP_ADD:
                case OP_ATTACH:
                case OP_REMOVE:
                case OP_DETACH: 
                ...
                break;
                case OP_REPLACE: {
                    final Fragment f = op.fragment;
                    final int containerId = f.mContainerId;
                    boolean alreadyAdded = false;
                    for (int i = added.size() - 1; i >= 0; i--) {
                        final Fragment old = added.get(i);
                        if (old.mContainerId == containerId) {
                            if (old == f) {
                                alreadyAdded = true;
                            } else {
                                // This is duplicated from above since we only make
                                // a single pass for expanding ops. Unset any outgoing primary nav.
                                if (old == oldPrimaryNav) {
                                    mOps.add(opNum, new Op(OP_UNSET_PRIMARY_NAV, old));
                                    opNum++;
                                    oldPrimaryNav = null;
                                }
                                final Op removeOp = new Op(OP_REMOVE, old);
                                removeOp.enterAnim = op.enterAnim;
                                removeOp.popEnterAnim = op.popEnterAnim;
                                removeOp.exitAnim = op.exitAnim;
                                removeOp.popExitAnim = op.popExitAnim;
                                mOps.add(opNum, removeOp);
                                added.remove(old);
                                opNum++;
                            }
                        }
                    }
                    if (alreadyAdded) {
                        mOps.remove(opNum);
                        opNum--;
                    } else {
                        op.cmd = OP_ADD;
                        added.add(f);
                    }
                }
                break;
                case OP_SET_PRIMARY_NAV: 
                break;
            }
        }
    }

再来看doPendingDeferredStart();

/**
 * FragmengManager:以下代码都在此类中
 */
void doPendingDeferredStart() {
    startPendingDeferredFragments();
}

void startPendingDeferredFragments() {
    performPendingDeferredStart(f);
}

public void performPendingDeferredStart(Fragment f) {
    moveToState(f, mCurState, 0, 0, false);
}

/**
 * 第三个核心:这里代码筛选过,但也比较多,主要为了说明:
 *     - 这里的case,跟生命周期匹配,有顺序的
 *     - 所有的case都没有break,所以会fall through向下继续执行
 *     - 不同的状态进来,执行的起始点也就不一样
 *     - 里面能看到我们经常使用到的生命周期方法的调用
 * 回到核心,我们的R.id.fl_content,在这里转换成ViewGroup container,然后Fragment的布局通过performCreateView()填充成View后,再通过container.addView()进去了
 */
void moveToState(Fragment f, int newState, int transit, int transitionStyle,
                 boolean keepActive) {
    switch (f.mState) {
        case Fragment.INITIALIZING:
            if (newState > Fragment.INITIALIZING) {

                dispatchOnFragmentPreAttached(f, mHost.getContext(), false);
                f.onAttach(mHost.getContext());

                dispatchOnFragmentAttached(f, mHost.getContext(), false);

                if (!f.mIsCreated) {
                    dispatchOnFragmentPreCreated(f, f.mSavedFragmentState, false);
                    f.performCreate(f.mSavedFragmentState);
                    dispatchOnFragmentCreated(f, f.mSavedFragmentState, false);
                } else {
                    f.restoreChildFragmentState(f.mSavedFragmentState);
                    f.mState = Fragment.CREATED;
                }

            }
            // fall through
        case Fragment.CREATED:

            if (newState > Fragment.CREATED) {
                if (!f.mFromLayout) {
                    ViewGroup container = null;
                    if (f.mContainerId != 0) {
                        if (f.mContainerId == View.NO_ID) {
                            throwException(new IllegalArgumentException(
                                    "Cannot create fragment "
                                            + f
                                            + " for a container view with no id"));
                        }
                        container = (ViewGroup) mContainer.onFindViewById(f.mContainerId);
                        if (container == null && !f.mRestored) {
                            String resName;
                            try {
                                resName = f.getResources().getResourceName(f.mContainerId);
                            } catch (Resources.NotFoundException e) {
                                resName = "unknown";
                            }
                            throwException(new IllegalArgumentException(
                                    "No view found for id 0x"
                                            + Integer.toHexString(f.mContainerId) + " ("
                                            + resName
                                            + ") for fragment " + f));
                        }
                    }
                    f.mContainer = container;
                    f.mView = f.performCreateView(f.performGetLayoutInflater(
                            f.mSavedFragmentState), container, f.mSavedFragmentState);
                    if (f.mView != null) {
                        f.mInnerView = f.mView;
                        f.mView.setSaveFromParentEnabled(false);
                        if (container != null) {
                            container.addView(f.mView);
                        }
                        if (f.mHidden) {
                            f.mView.setVisibility(View.GONE);
                        }
                        f.onViewCreated(f.mView, f.mSavedFragmentState);
                        dispatchOnFragmentViewCreated(f, f.mView, f.mSavedFragmentState,
                                false);
                        // Only animate the view if it is visible. This is done after
                        // dispatchOnFragmentViewCreated in case visibility is changed
                        f.mIsNewlyAdded = (f.mView.getVisibility() == View.VISIBLE)
                                && f.mContainer != null;
                    } else {
                        f.mInnerView = null;
                    }
                }

                f.performActivityCreated(f.mSavedFragmentState);
                dispatchOnFragmentActivityCreated(f, f.mSavedFragmentState, false);
                if (f.mView != null) {
                    f.restoreViewState(f.mSavedFragmentState);
                }
                f.mSavedFragmentState = null;
            }
            // fall through
        case Fragment.ACTIVITY_CREATED:
            if (newState > Fragment.ACTIVITY_CREATED) {
                f.mState = Fragment.STOPPED;
            }
            // fall through
        case Fragment.STOPPED:
            if (newState > Fragment.STOPPED) {
                if (DEBUG) Log.v(TAG, "moveto STARTED: " + f);
                f.performStart();
                dispatchOnFragmentStarted(f, false);
            }
            // fall through
        case Fragment.STARTED:
            if (newState > Fragment.STARTED) {
                if (DEBUG) Log.v(TAG, "moveto RESUMED: " + f);
                f.performResume();
                dispatchOnFragmentResumed(f, false);
                f.mSavedFragmentState = null;
                f.mSavedViewState = null;
            }
    }
}

// Fragment: 这里performCreateView调用的是生命周期里的onCreateView(),其它performXXX也一样
View performCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
        @Nullable Bundle savedInstanceState) {
    // ...
    return onCreateView(inflater, container, savedInstanceState);
}

commit()终于告一段落。

3.2.5 流程复盘

回顾一下整个流程:

FragmentActivity.getSupportFragmentManager(): 
    <- FragmentController.getSupportFragmentManager()
    <- FragmentHostCallback.getFragmentManagerImpl()
    <- new FragmentManagerImpl()
FragmentManager.beginTranscation()
    <- new BackStackRecord(this):this 指 FragmentManagerImpl 对象

FragmentTranscation.replace(): 在实现类BackStackRecord里,把OP_REPLACE命令放入到待执行的操作集合中

BackStackRecord.commit(): 把提交操作交给 FragmentManagerImpl,然后通过 Handler 来 post Runnable 对象 mExecCommit,从这可以猜测 replace Fragment 可以在子线程中执行,经测试,没问题:

new Thread(new Runnable() {
    @Override
    public void run() {
        getSupportFragmentManager().beginTransaction().replace(R.id.fl_content, new TestFragment()).commit();
    }
}).start();

mExecCommit里主要做了两件事:

  • 把replace命令,在BackStackRecord中切换到下一步命令,核心方法:expandOps(ArrayList<Fragment> added, Fragment oldPrimaryNav)
  • 在FragmentManager中,根据生命周期,一步一步切换Fragment的当前状态,同时调用Fragment对应的生命周期。核心方法:void moveToState(Fragment f, int newState, int transit, int transitionStyle, boolean keepActive)

插曲:
中途分析moveToState(Fragment f, int newState, int transit, int transitionStyle, boolean keepActive)
f.performCreate(f.mSavedFragmentState);的时候,进入了生命周期的循环处理机制,可把我给绕晕了,后来跳过了。感兴趣的小伙伴可以去研究以下,大概包括了这几个类:LifecycleRegistry、Lifecycle、LifecycleOwner、LifecycleObserver、ObserverWithState、GenericLifecycleObserver及其实现类

3.2.6 addView(View)

addView(View)什么时候触发测量的呢?

// ViewGroup:
public void addView(View child, int index, LayoutParams params) {
    // addViewInner() will call child.requestLayout() when setting the new LayoutParams
    // therefore, we call requestLayout() on ourselves before, so that the child's request
    // will be blocked at our level
    requestLayout();
    invalidate(true);
    addViewInner(child, index, params, false);
}
3.2.7 requestLayout()

replace()也就是把Fragment的界面添加到container容器中,那直接让container.requestLayout()测量,是否有效呢?
还是看下源码:

// View:
public void requestLayout() {

    mPrivateFlags |= PFLAG_FORCE_LAYOUT;
    mPrivateFlags |= PFLAG_INVALIDATED;

    if (mParent != null && !mParent.isLayoutRequested()) {
        mParent.requestLayout();
    }
    if (mAttachInfo != null && mAttachInfo.mViewRequestingLayout == this) {
        mAttachInfo.mViewRequestingLayout = null;
    }
}

public boolean isLayoutRequested() {
    return (mPrivateFlags & PFLAG_FORCE_LAYOUT) == PFLAG_FORCE_LAYOUT;
}

可以看到,container的requestLayout(),肯定是让其父控件去测量,一级一级传上去,直到Activity的根布局DecorView。然后再由根布局一级一级向下测量onMeasure(),和布局onLayout()

而TestViewGroup里已经关闭了测量,所以即使调用container.requestLayout()也无效

3.2.8 解决方法

知道原因后,解决办法就很多了:

最直接地解决方法就是去掉isMeasured,onLayout()里的changed判断也去掉

如果硬要避免重复测量与布局,提高性能的话,那可以在请求重新测量的时候,把测量标记和布局标记复位:

private boolean isMeasured;
private boolean isLayouted;

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    if (!isMeasured) {
        ...
        isMeasured = true;
    }
    setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), MeasureSpec.getSize(heightMeasureSpec));
}

@Override
public void requestLayout() {
    isMeasured = false;
    isLayouted = false;
    super.requestLayout();
}


@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
    if (!isLayouted) {
        ...
        isLayouted = true;
    }
}

再假设一种情况:如果已经封装好了,那就用万能的发射修改isMeasured;同时重写onLayout(),强制给给super的changed传true

四、总结

  1. 找问题还要从源码入手,才能找到根源,才能柳暗花明
  2. 需要多看源码,才能更快地抓住核心点
  3. 语言组织能力亟待提高,写了好三个晚上才搞定

五、参考

《通过源码解析 Fragment 启动过程》

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值