(4.0.23.10)源码分析已嵌套的Fragment遇到ViewPager

近期在修改页面结构过程中,遇到了一个特殊的问题,现在做个总结。

一、背景介绍

先说下Feed首页的页面结构,为了满足运营多种动态化的需求,Feed首页采用了以下图所示的页面结构,从页面到最小粒度的控件可以分成5个层级

index.png

  1. HomePageActivity
    • 负责解析跳转参数、页面埋点、初始化等相关逻辑
  2. TabContainerFragment
    • Homepage真正的界面主体是通过该Fragment去承载的,内部是可以切换的一个tablayout;
    • 满足了运营对“HP上的tabs可以动态配置,灵活添加”
  3. TabItemFragment
    • 由FeedBaseFragment延伸出来的各种不同Feed流, 主体是一个列表,呈现Feed流信息;
    • 满足了运营对“不同的tab可以动态配置数据源,以呈现不同的内容聚合性”
  4. FeedItem
    • 具体的一个Feed信息;
    • 满足了运营对“不同的Feed类型要有不同的Feed样式进行承载、甚至是氛围区别”;
  5. Pdp
    • Feed信息所包含的商品图片、或者自定义图片
    • 满足了运营对“Feed可以透出商品图或者自定义图等信息,来吸引用户的眼球,快速表述feed的语义”;

再介绍下修改的原由:应用原来的使用的主框架是tabhost + activity,最近android架构升级要求替换为FragmentTabHost,为了同时满足并行开发和尽量保证代码逻辑改变不大, 因此计划将原FeedHomePageActivity中的业务再次封装为一个FeedHomeFragment给框架团队,其内部逻辑和FeedHomePageActivity保持一致

其中一些细节调整不再赘述,我们进入本篇的正题: 在将Activity替换为Fragment后,我们发现原来Viepager中的TabFragment所设计的基于setMenuVisibility的可视生命周期监听失效了,而该生命周期承载了我们重要的业务功能,由此引发了我们对原因的分析。

二、问题描述

TabItemFragment 原来使用了 setMenuVisibility 来判断页面是否对用户可见

	private boolean isExitViewpager = false;
    protected boolean isViewCreated = false;
    protected boolean isMenuVisible = false;

    @Override
    public void setMenuVisibility(boolean menuVisible) {
        super.setMenuVisibility(menuVisible);
        LLog.i(TAG, String.format("%s : %s : %s : %s", pageTagMark(), "setUserVisibleHint", menuVisible, isViewCreated));
        isMenuVisible = menuVisible;
        if (!menuVisible
                && isExitViewpager
                && isViewCreated) {
            onPagePause();
        }

        if (menuVisible
                && isExitViewpager
                && isViewCreated) {
            onPageStart();
        }
    }

    @Override
    public void onResume() {
        super.onResume();
        LLog.i(TAG, String.format("%s : %s : %s : %s", pageTagMark(), "onResume", isViewCreated, isMenuVisible));
        if (isExitViewpager) {
            if (isViewCreated && isMenuVisible) {
                onPageStart();
            }
        } else {
            onPageStart();
        }
    }

    @Override
    public void onPause() {
        super.onPause();
        LLog.i(TAG, String.format("%s : %s", pageTagMark(), "onPause"));
        if (isExitViewpager) {
            if (isMenuVisible) {
                onPagePause();
            }
        } else {
            onPagePause();
        }
    }

当顶级容器为Activity时该判断表现良好,但是当在新框架中FragmentTabHost中时,发现再次回到Feed频道后,TabItemFragment#onResume方法中的isMenuVisible竟然变为了false, 这就引发了onPageStart()方法没有调用!

通过初步分析,我们发现新老结构的差异在于,新框架下重回Feed渠道时会触发FragmentStatePagerAdapter.restoreState,该函数调用了setMenuVisibility(false),哪怕页面时处于显示状态的

那么这个函数是什么作用,为什么会被新框架调用呢? 我们从新框架引发的变化开始分析

三、FragmentTabHost 对FeedHomeFragment生命周期的影响

我们知道Fragment的常见生命周期如下:

20140719225005356.png

  1. onAttach
  2. onCreate
  3. onCreateView
  4. onViewCreated
  5. onResume
  6. onPause
  7. onStop
  8. onDestroyView
  9. onDestroy
  10. onDetach

我们对fragment进行管理时,往往使用的是FragmentManager, 其中有两对关键函数:

  • add\remove(replace)
    • remove从Activity中移除一个Fragment时,如果被移除的Fragment没有添加到回退栈,这个Fragment实例将会被销毁
    • 被移除的Fragment的调用顺序为:onPause, onStop, onDestroyView, onDestroy, onDetach
    • 被移除的Fragment再次加入后顺序为:从onAttach开始
    • 如果remove后,使用addToBackStack函数
      • 只存在三种状态的切换:onPause,onStop,onDestroyView
      • 仅仅只是界面被销毁onDestroyView,而fragOne对象的实例依然被保存在FragmentManager中(因为无onDestroy,onDetach),它的部分状态依然被保存在FragmentManager中
      • 再次加入会直接从onCreateView开始
  • attach() detach()
    • 重建view视图,附加到UI上并显示
    • attach()->onCreateView()->onActivityCreated()->onStart()->onResume()
    • detach()->onPause()->onStop()->onDestroyView()
  • show()/hide()
    • 使用show()/hide()时一般是会使用addToBackStack,,因为要使用show()/hide()前提是该Fragment实例已经存在,只不过我们是否将其界面显示出来
    • hide从Activity中隐藏一个Fragment时,不会触发上述的生命周期函数

那么 FragmentTabHost 使用的是哪种方式呢?我们进入源码分析,入口从 onTabChanged(String tabId) 开始:

    public void onTabChanged(String tabId) {
        if (this.mAttached) {
            FragmentTransaction ft = this.doTabChanged(tabId, (FragmentTransaction)null);
            if (ft != null) {
                ft.commit();
            }
        }

        if (this.mOnTabChangeListener != null) {
            this.mOnTabChangeListener.onTabChanged(tabId);
        }

    }

其中会调用doTabChanged(@Nullable String tag, @Nullable FragmentTransaction ft)去获取一个FragmentTransaction,并commit提交

    @Nullable
    private FragmentTransaction doTabChanged(@Nullable String tag, @Nullable FragmentTransaction ft) {
        FragmentTabHost.TabInfo newTab = this.getTabInfoForTag(tag);
        if (this.mLastTab != newTab) {
            if (ft == null) {
                ft = this.mFragmentManager.beginTransaction();
            }

            if (this.mLastTab != null && this.mLastTab.fragment != null) {
                ft.detach(this.mLastTab.fragment);
            }

            if (newTab != null) {
                if (newTab.fragment == null) {
                    newTab.fragment = Fragment.instantiate(this.mContext, newTab.clss.getName(), newTab.args);
                    ft.add(this.mContainerId, newTab.fragment, newTab.tag);
                } else {
                    ft.attach(newTab.fragment);
                }
            }

            this.mLastTab = newTab;
        }

        return ft;
    }

我们看到其中:

  1. 对当前的Fragment,调用了FragmentTransaction.detach
  2. 对tab对应的fragment进行了实例化,如果已经有实例的fragment,会触发FragmentTransaction的attach预操作

我们吧目光转向FragmentTransaction的实现类BackStackRecord:

   public FragmentTransaction attach(Fragment fragment) {
        this.addOp(new BackStackRecord.Op(7, fragment));
        return this;
    }

    public FragmentTransaction detach(Fragment fragment) {
        this.addOp(new BackStackRecord.Op(6, fragment));
        return this;
    }


内部维护一个操作栈队列( ArrayList<BackStackRecord.Op> mOps = new ArrayList();),执行了对应的入队操纵。 真正的执行是在commit函数中

    public int commit() {
        return this.commitInternal(false);
    }

    int commitInternal(boolean allowStateLoss) {
        if (this.mCommitted) {
            throw new IllegalStateException("commit already called");
        } else {
            if (FragmentManagerImpl.DEBUG) {
                Log.v("FragmentManager", "Commit: " + this);
                LogWriter logw = new LogWriter("FragmentManager");
                PrintWriter pw = new PrintWriter(logw);
                this.dump("  ", (FileDescriptor)null, pw, (String[])null);
                pw.close();
            }

            this.mCommitted = true;
            if (this.mAddToBackStack) {
                this.mIndex = this.mManager.allocBackStackIndex(this);
            } else {
                this.mIndex = -1;
            }

            this.mManager.enqueueAction(this, allowStateLoss);
            return this.mIndex;
        }
    }

我们现在把目光转移到回调BackStackRecord#executeOps():

 void executeOps() {
        int numOps = this.mOps.size();

        for(int opNum = 0; opNum < numOps; ++opNum) {
            BackStackRecord.Op op = (BackStackRecord.Op)this.mOps.get(opNum);
            Fragment f = op.fragment;
            if (f != null) {
                f.setNextTransition(this.mTransition, this.mTransitionStyle);
            }

            switch(op.cmd) {
            case 1:
                f.setNextAnim(op.enterAnim);
                this.mManager.addFragment(f, false);
                break;
            case 2:
            default:
                throw new IllegalArgumentException("Unknown cmd: " + op.cmd);
            case 3:
                f.setNextAnim(op.exitAnim);
                this.mManager.removeFragment(f);
                break;
            case 4:
                f.setNextAnim(op.exitAnim);
                this.mManager.hideFragment(f);
                break;
            case 5:
                f.setNextAnim(op.enterAnim);
                this.mManager.showFragment(f);
                break;
            case 6:
                f.setNextAnim(op.exitAnim);
                this.mManager.detachFragment(f);
                break;
            case 7:
                f.setNextAnim(op.enterAnim);
                this.mManager.attachFragment(f);
                break;
            case 8:
                this.mManager.setPrimaryNavigationFragment(f);
                break;
            case 9:
                this.mManager.setPrimaryNavigationFragment((Fragment)null);
            }

            if (!this.mReorderingAllowed && op.cmd != 1 && f != null) {
                this.mManager.moveFragmentToExpectedState(f);
            }
        }

        if (!this.mReorderingAllowed) {
            this.mManager.moveToState(this.mManager.mCurState, true);
        }

    }

可以看到最终调用了 FragmentManagerImpl的attach|detach, 由此我们也确认了:

  • ** FragmentTabHost使用了detach|attach 对fragment进行了操作**
    1. 切换走时的生命周期调用顺序为:onPause, onStop, onDestroyView
    2. 再次切换回来时生命周期顺序为从onCreateView、onViewCreated、onResume
    3. FragmentTabHost简单用法,以及Fragment生命周期

这里解释了,重新回到FeedHomeFragment时它的生命周期是正常被调用的。

由于FeedHomeFragment直接包裹了 FeedTabContainerFragment,那FeedTabContainerFragment 的生命周期也是正常的

那么为什么ViewPage里的TabItemFragment会出现 setMenuVisibility 异常呢?

四、ViewPager遇上Fragment

ViewPager有一个方法setOffscreenPageLimit(int limit),该方法设置保存当前页面两侧各limit个页面,已经超出limit的部分会被销毁,该值默认为1。

假设我们有0,1,2,3四个页面,一开始ViewPager的currentItem为0,此时会预加载1页面;滑动到1页面时候,因为0已经加载过了,此时会预加载2页面;当滑动到2时候,就会销毁0,预加载3。其中加载时会调用PagerAdapter的instantiateItem(ViewGroup container, int position)方法,销毁时会调用destroyItem(ViewGroup container, int position, Object object)方法。

我们看下日志记录:

  1. 进入第一个页面
    • 1.jpg
  2. 进入第二个页面
    • 2.jpg
    • 当滑动到第二个fragment的时候,可以看到fragment3开始创建

我们可以看到当Fragment遇到ViewPager时,通过生命周期直接判断 界面是否对用户可见已经不可靠了,业界常用的进行真正判断的函数是借助setMenuVisibility 或者 setUserVisibleHint 进行实现的

    @Override
    public void setMenuVisibility(boolean menuVisible) {
        super.setMenuVisibility(menuVisible);
        if (menuVisible) {
            //相当于Fragment的onResume
        } else {
            //相当于Fragment的onPause
        }
    }

我们在原框架结构中是使用这种方式去实现的,经过验证也是真实可靠的,但是为什么在新架构中就失效了呢?

进一步的,我们联想到了可能是Fragment的状态保存和恢复引发的异常

五、Fragment的状态保存和恢复

先上大招,看下整体的时序图分析:

sequence_diagram.jpg

  • 我们知道FragmentTabHost最终是通过FragmentManager的attachFragment|detachFragment来管理的Fragment的(会触发onattach和ondeatch,会触发viewcreate和destory),就是说ShopStreeMainTabFragment的视图会经历销毁和重建。

  • 内部会走到 void moveFragmentToExpectedState(final Fragment f) 对fragment进行操作,其中有几个关键生命周期阶段:

    static final int INVALID_STATE = -1;   // Invalid state used as a null value.
    static final int INITIALIZING = 0;     // Not yet created.
    static final int CREATED = 1;          // Created.
    static final int ACTIVITY_CREATED = 2; // The activity has finished its creation.
    static final int STOPPED = 3;          // Fully created, not started.
    static final int STARTED = 4;          // Created and started, not resumed.
    static final int RESUMED = 5;          // Created started and resumed.
  • 最终触发moveToState(Fragment f, int newState, int transit, int transitionStyle, boolean keepActive)
    • fragment的状态在向前move的时候,即变大到resume状态的过程中
    • 向后移动,即隐藏
    void moveToState(Fragment f, int newState, int transit, int transitionStyle,
            boolean keepActive) {

          if (f.mState <= newState) {
          
          	  switch (f.mState) {
          	  	...
          	  	 case Fragment.CREATED:
          	  	 	container = mContainer.onFindViewById(f.mContainerId);
          	  	 	f.mContainer = container;
                    f.mView = f.performCreateView(f.performGetLayoutInflater(
                                    f.mSavedFragmentState), container, f.mSavedFragmentState);
                    if (f.mView != null) {
                  	   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);
	                }
	                f.performActivityCreated(f.mSavedFragmentState);
                    dispatchOnFragmentActivityCreated(f, f.mSavedFragmentState, false);
                    if (f.mView != null) {
                         f.restoreViewState(f.mSavedFragmentState);
                    }
                    f.mSavedFragmentState = null;


          	  }
          }else{
 			 switch (f.mState) {
          	  	...
          	  	case Fragment.STOPPED:
                case Fragment.ACTIVITY_CREATED:
                	 saveFragmentViewState(f);
                	 f.performDestroyView();
                     dispatchOnFragmentViewDestroyed(f, false);
          } 

    }
  • 那么“进入feed,切到其他tab,再重新切回feed时”,重建过程会依次触发 ShopStreeMainTabFragment的生命周期(ShopStreeMainTabFragment当前状态为1)

    1. performCreateView
    2. onViewCreated
    3. performActivityCreated
    4. dispatchOnFragmentActivityCreated
    5. if (f.mView != null) { f.restoreViewState(f.mSavedFragmentState);}
      • 重建的fragment会触发 f.restoreViewState(f.mSavedFragmentState); 这个state会有值
  • start

  • resume

4.1 Fragment#restoreViewState(f.mSavedFragmentState)的参数

我们先来看restoreViewState中的源码:

  • mInnerView.restoreHierarchyState(this.mSavedViewState); 并回调onViewStateRestored(savedInstanceState);
    final void restoreViewState(Bundle savedInstanceState) {
        if (mSavedViewState != null) {
            mView.restoreHierarchyState(mSavedViewState);
            mSavedViewState = null;
        }
        mCalled = false;
        onViewStateRestored(savedInstanceState);
        if (!mCalled) {
            throw new SuperNotCalledException("Fragment " + this
                    + " did not call through to super.onViewStateRestored()");
        }
    }
  • 回到 FragmentManager, 传入的mSavedViewState其实是在 隐藏fragment过程中的saveFragmentViewState(fragment)中保存
    void saveFragmentViewState(Fragment f) {
        if (f.mView == null) {
            return;
        }
        if (mStateArray == null) {
            mStateArray = new SparseArray<Parcelable>();
        } else {
            mStateArray.clear();
        }
        f.mView.saveHierarchyState(mStateArray);
        if (mStateArray.size() > 0) {
            f.mSavedViewState = mStateArray;
            mStateArray = null;
        }
    }
  • 对于View的saveHierarchyState
    public void saveHierarchyState(SparseArray<Parcelable> container) {
        dispatchSaveInstanceState(container);
    }

    protected void dispatchSaveInstanceState(SparseArray<Parcelable> container) {
        if (mID != NO_ID && (mViewFlags & SAVE_DISABLED_MASK) == 0) {
            mPrivateFlags &= ~PFLAG_SAVE_STATE_CALLED;
            Parcelable state = onSaveInstanceState();
            if ((mPrivateFlags & PFLAG_SAVE_STATE_CALLED) == 0) {
                throw new IllegalStateException(
                        "Derived class did not call super.onSaveInstanceState()");
            }
            if (state != null) {
                // Log.i("View", "Freezing #" + Integer.toHexString(mID)
                // + ": " + state);
                container.put(mID, state);
            }
        }
    }
  • 对于ViewGroup,会遍历子view#onSaveInstanceState();
    @Override
    protected void dispatchSaveInstanceState(SparseArray<Parcelable> container) {
        super.dispatchSaveInstanceState(container);
        final int count = mChildrenCount;
        final View[] children = mChildren;
        for (int i = 0; i < count; i++) {
            View c = children[i];
            if ((c.mViewFlags & PARENT_SAVE_DISABLED_MASK) != PARENT_SAVE_DISABLED) {
                c.onSaveInstanceState(container);
            }
        }
    }
  • 那我们来看下ViewPager的onSaveInstanceState
    public Parcelable onSaveInstanceState() {
        Parcelable superState = super.onSaveInstanceState();
        ViewPager.SavedState ss = new ViewPager.SavedState(superState);
        ss.position = this.mCurItem;
        if (this.mAdapter != null) {
            ss.adapterState = this.mAdapter.saveState();
        }

        return ss;
    }
  • 调用了adapter的saveState(),对于FragmentStatePagerAdapter就是读取之前fragment.setInitialSavedState(fss);的数组fss, 写入bundle

4.2 遍历嵌套Fragment中

  • ShopStreeMainTabFragment会内部调用
    1. onActivityCreated
    2. mChildFragmentManager.dispatchActivityCreated)
	void performActivityCreated(Bundle savedInstanceState) {
        if (mChildFragmentManager != null) {
            mChildFragmentManager.noteStateNotSaved();
        }
        mState = ACTIVITY_CREATED;
        mCalled = false;
        onActivityCreated(savedInstanceState);
        if (!mCalled) {
            throw new SuperNotCalledException("Fragment " + this
                    + " did not call through to super.onActivityCreated()");
        }
        if (mChildFragmentManager != null) {
            mChildFragmentManager.dispatchActivityCreated();
        }
    }
  • 其中dispatchActivityCreated会触发dispatchStateChange
    public void dispatchActivityCreated() {
        mStateSaved = false;
        dispatchMoveToState(Fragment.ACTIVITY_CREATED);
    }

    private void dispatchMoveToState(int state) {
	    if (mAllowOldReentrantBehavior) {
	        moveToState(state, false);
	    } else {
	        try {
	            mExecutingActions = true;
	            moveToState(state, false);
	        } finally {
	            mExecutingActions = false;
	        }
	    }
	    execPendingActions();
    }

    void moveToState(int newState, boolean always) {

		for (int i = 0; i < numAdded; i++) {
            Fragment f = mAdded.get(i);
            moveFragmentToExpectedState(f);
            if (f.mLoaderManager != null) {
                loadersRunning |= f.mLoaderManager.hasRunningLoaders();
            }
        }
    }

  • moveToState(nextState, false);依次调用持有的child fragments的对应方法生命周期【feedcontainer\following\explore】(当前状态为1))
    1. 触发feedTabContainerFragment#moveFragmentToExpectedState
      1. (f.mView != null) { f.restoreViewState(f.mSavedFragmentState);}
        1. innerView.restoreHierarchyState(this.mSavedViewState);
          1. FrameLayout# dispatchRestoreInstanceState(container); 遍历子view
            1. View#dispatchRestoreInstanceState(container);
              1. ViewPager#onRestoreInstanceState
              2. mAdapter.restoreState(ss.adapterState, ss.loader);
              3. FragmentStatePagerAdapter
    2. 触发following#moveFragmentToExpectedState
    3. 触发explore#moveFragmentToExpectedState

4.3 FragmentStatePagerAdapter

我们使用了FragmentStatePagerAdapter,它缓存的Fragment是放在mFragments集合中的,当调用destroyItem时候会调用 mFragments.set(position, null)移除对应的实例

	@Override
    public Object instantiateItem(ViewGroup container, int position) {
        //mFragments中保存ViewPager缓存的页面对应的Fragment实例,如果在缓存中就直接返回啦
        if (mFragments.size() > position) {
            Fragment f = mFragments.get(position);
            if (f != null) {
                return f;
            }
        }
        if (mCurTransaction == null) {
            mCurTransaction = mFragmentManager.beginTransaction();
        }
        Fragment fragment = getItem(position);
        if (DEBUG) Log.v(TAG, "Adding item #" + position + ": f=" + fragment);
        if (mSavedState.size() > position) {
           //是否之前保存过该页面的状态,保存过就恢复
            Fragment.SavedState fss = mSavedState.get(position);
            if (fss != null) {
                fragment.setInitialSavedState(fss);
            }
        }
        while (mFragments.size() <= position) {
            mFragments.add(null);
        }
        fragment.setMenuVisibility(false);
        fragment.setUserVisibleHint(false);
        mFragments.set(position, fragment);
        mCurTransaction.add(container.getId(), fragment);

        return fragment;
    }

    @Override
    public void destroyItem(ViewGroup container, int position, Object object) {
        Fragment fragment = (Fragment) object;

        if (mCurTransaction == null) {
            mCurTransaction = mFragmentManager.beginTransaction();
        }
        if (DEBUG) Log.v(TAG, "Removing item #" + position + ": f=" + object
                + " v=" + ((Fragment)object).getView());
        while (mSavedState.size() <= position) {
            mSavedState.add(null);
        }
        //保存该Fragment的状态
        mSavedState.set(position, fragment.isAdded()
                ? mFragmentManager.saveFragmentInstanceState(fragment) : null);
        //将缓存对应位置置空
        mFragments.set(position, null);
        //销毁实例
        mCurTransaction.remove(fragment);
    }

	 public Parcelable saveState() {
	 
	 }

    public void restoreState(Parcelable state, ClassLoader loader) {

    	... 
    	f.setMenuVisibility(false);
    	...
    }

需要注意的是当视图重建时:

  1. ViewPager离屏缓存的Fragment,FragmentManager会帮助我们恢复
  2. 从来没有add到过FragmentManager中实例,我们在getItem放法中创建就好
  3. 曾经add到过Fragment实例,要保留之前的视图状态,依靠的是saveState | restoreState

其中restoreState会强制把 setMenuVisibility 置为false

结论

新框架中TabItemFragment的setMenuVisibility异常是由于:

  1. 新框架使用了 FragmentTabHost
  2. FragmentTabHost使用了 FragmentManager#attachFragment|detachFragment
  3. 终触发moveToState(
  4. 顶级Fragment的performCreateView、onViewCreated、performActivityCreated
  5. 其中performActivityCreated会遍历嵌套的Fragment去调用moveToState函数
    1. moveToState中会触发 mInnerView的restoreState.
      • f.mView会在onDestroyView之前自动保存view树状态,并且在(同一个实例)onActivityCreate之后、onStart之前自动恢复之前保存的状态;
    2. 对于FeedTabContainerFragment,会触发ViewPager的onRestoreInstanceState
    3. 内部调用mAdapter.restoreState(ss.adapterState, ss.loader)
    4. FragmentStatePagerAdapter 会强制setMenuVisibility(false)

问题查明后,我们的解决方案其实也出来了,FragmentStatePagerAdapter恢复视图状态时只强制调用了setMenuVisibility(false),并没有影响setUserVisibleHint,因此将相关的判断改为setUserVisibleHint即可

@Override
public void setUserVisibleHint(boolean isVisibleToUser) {
    super.setUserVisibleHint(isVisibleToUser);
    if (getUserVisibleHint()) {
   //界面可见
    } else {
	//界面不可见 相当于onpause
    }
}

参考文献

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值