Android 适配深色模式

Android 深色模式适配

Android 10 开始支持配置深色模式,如果系统是深色主题,但是打开APP又是浅色主题就会显得格格不入。下面介绍几种适配深色模式的方法。

一、forceDarkAllowed

样式中设置 android:forceDarkAllowed 属性,深色主题下系统会自动进行适配。

  1. 新建 values-v29 目录,因为 android:forceDarkAllowed 属性 Android 10开始才有。

  2. 设置 android:forceDarkAllowed 属性为true
    在这里插入图片描述

  3. 适配效果

布局文件:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:app="http://schemas.android.com/apk/res-auto"
  xmlns:tools="http://schemas.android.com/tools"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  tools:context=".MainActivity">
  <Button
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:minWidth="200dp"
    android:minHeight="100dp"
    app:layout_constraintLeft_toLeftOf="parent"
    app:layout_constraintRight_toRightOf="parent"
    app:layout_constraintTop_toTopOf="parent"
    app:layout_constraintBottom_toBottomOf="parent"
    android:text="深色模式"
    />
</androidx.constraintlayout.widget.ConstraintLayout>

浅色主题:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TYdMNe7a-1639920853307)(E:\ws\技术分享\APP换肤实现\换肤2.png)]

深色主题:
在这里插入图片描述

从布局文件中可以看到,并没有设置任何背景色,但深色主题下,APP自动进行了适配。

这种适配方式十分简单,但是不够美观,无法自定义控件颜色样式,全凭系统控制,并不推荐这种自动化方式实现深色模式。

二、设置深色主题

官方推荐另外一种方法,即分别创建浅色和深色的主题样式。

  1. 新建 values-night 目录,存放深色主题的样式
    在这里插入图片描述

  2. 适配效果

浅色主题:
在这里插入图片描述

深色主题:
在这里插入图片描述

forceDarkAllowed 最大的区别在于,深色主题可以手动设置颜色样式。

一些常用的方法:

  • 判断深色主题
  public static boolean isDarkTheme(Context context) {
    int flag = context.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK;
    return flag == Configuration.UI_MODE_NIGHT_YES;
  }
  • 代码中切换深色主题
if (isDarkTheme(MainActivity.this)) {
        AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO);
      } else {
        AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES);
      }
  • 禁止界面适配深色主题

Activity 的 configChanges 属性当中配置 uiMode 避免Activity 重新创建,从而阻止界面适配深色主题。

<activity
      android:exported="true"
      android:name=".MainActivity"
      android:configChanges="uiMode">
      <intent-filter>
        <action android:name="android.intent.action.MAIN" />

        <category android:name="android.intent.category.LAUNCHER" />
      </intent-filter>
    </activity>

这时候虽然界面不会重新创建,但是会触发 onConfigurationChanged 方法回调,可以根据回调做一些处理。

  @Override
  public void onConfigurationChanged(@NonNull Configuration newConfig) {
    super.onConfigurationChanged(newConfig);
    int mSysThemeConfig = newConfig.uiMode & Configuration.UI_MODE_NIGHT_MASK;
    switch (mSysThemeConfig) {
      // 亮色主题
      case Configuration.UI_MODE_NIGHT_NO:
        break;
      // 深色主题
      case Configuration.UI_MODE_NIGHT_YES:
        break;
      default:
        break;
    }
  }

三、使用 Android-skin-support

上述两种方式只支持Android 10 系统,且系统切换深色主题界面会重新创建,并不是太灵活。如果想适配Android 10 以下的系统可以使用 Android-skin-support 框架实现。

Android-skin-support 是一个换肤框架,通过加载不同的皮肤包从而实现换肤,深色模式只需要创建对应的深色主题皮肤包,然后替换当前的默认样式就可以实现适配。

它的实现流程大致如下:

  1. 控制View的创建,将所有View替换为对应的SkinxxxView
  2. SkinxxxView中会根据布局中的属性ID值,找到皮肤包中对应的资源进行替换
  3. 动态换肤,即通知所有的SkinXXXView进行更新

3.1 Android View 创建流程

在使用 Android-skin-support 之前,可以先了解下 Android View 创建流程,有利于我们之后使用该库。

从 setContentView 方法开始,它作用就是设置界面布局资源。

	//Activity
    public void setContentView(@LayoutRes int layoutResID) {
        getWindow().setContentView(layoutResID);
        initWindowDecorActionBar();
    }

调用的Window中的 setContentView 方法。

    //PhoneWindow
    @Override
    public void setContentView(int layoutResID) {
		......
        if (hasFeature(FEATURE_CONTENT_TRANSITIONS)) {
            final Scene newScene = Scene.getSceneForLayout(mContentParent, layoutResID,
                    getContext());
            transitionTo(newScene);
        } else {
            mLayoutInflater.inflate(layoutResID, mContentParent);
        }
        ......
    }

调用 LayoutInflater 的 inflate 方法

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

可以看到根据布局资源创建一个Xml 解析器进行解析

    public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
        			......
                    // Temp is the root view that was found in the xml
                    final View temp = createViewFromTag(root, name, inflaterContext, attrs);
					......
                    // Inflate all children under temp against its context.
                    rInflateChildren(parser, temp, attrs, true);
					......
        }
    }

先通过createViewFromTag方法创建rootView,然后再使用rInflateChildren解析子布局,最终都是通过createView创建View

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

     ......
}

    public final View tryCreateView(@Nullable View parent, @NonNull String name,
        @NonNull Context context,
        @NonNull AttributeSet 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;
        }
        ......
    }

可以看到如果mFactory2不为空则通过mFactory2来创建View,而mFactory2又是哪里进行初始化的呢?

通过查看代码发现,mFactory2初始化代码如下:

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

	//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");
            }
        }
    }

    public static void setFactory2(
            @NonNull LayoutInflater inflater, @NonNull LayoutInflater.Factory2 factory) {
        inflater.setFactory2(factory);
		......
    }

    public void setFactory2(Factory2 factory) {
        //mFactory2 不可以重复设置,否则会直接抛出异常
        if (mFactorySet) {
            throw new IllegalStateException("A factory has already been set on this LayoutInflater");
        }
        if (factory == null) {
            throw new NullPointerException("Given factory can not be null");
        }
        mFactorySet = true;
        if (mFactory == null) {
            mFactory = mFactory2 = factory;
        } else {
            mFactory = mFactory2 = new FactoryMerger(factory, factory, mFactory, mFactory2);
        }
    }

上述代码可以发现,mFactory2其实就是AppCompatDelegateImpl,现在我们再看下AppCompatDelegateImpl的 onCreateView方法

@Override
public View createView(View parent, final String name, @NonNull Context context,
        @NonNull AttributeSet attrs) {
	......
    return mAppCompatViewInflater.createView(parent, name, context, attrs, inheritContext,
            IS_PRE_LOLLIPOP, /* Only read android:theme pre-L (L+ handles this anyway) */
            true, /* Read read app:theme as a fallback at all times for legacy reasons */
            VectorEnabledTintResources.shouldBeUsed() /* Only tint wrap the context if enabled */
    );
}

    final View createView(View parent, final String name, @NonNull Context context,
            @NonNull AttributeSet attrs, boolean inheritContext,
            boolean readAndroidTheme, boolean readAppTheme, boolean wrapContext) {
		......
        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;
            case "Button":
                view = createButton(context, attrs);
                verifyNotNull(view, name);
                break;
            case "EditText":
                view = createEditText(context, attrs);
                verifyNotNull(view, name);
                break;
			......
            default:
                // The fallback that allows extending class to take over view inflation
                // for other tags. Note that we don't check that the result is not-null.
                // That allows the custom inflater path to fall back on the default one
                // later in this method.
                view = createView(context, name, attrs);
        }
		......
        return view;
    }

    @NonNull
    protected AppCompatTextView createTextView(Context context, AttributeSet attrs) {
        return new AppCompatTextView(context, attrs);
    }

上述代码可以看到,创建View其实是通过控件的名称,然后再new对应的控件,通过createTextView可以发现创建的也不是TextView,而是AppCompatTextView,所以只要能够修改mFactory2,就可以控制所有View的创建。

3.2 使用方法

集成 Android-skin-support
implementation 'skin.support:skin-support:4.0.5'                   // skin-support
    implementation 'skin.support:skin-support-appcompat:4.0.5'         // skin-support 基础控件支持
    implementation 'skin.support:skin-support-design:4.0.5'            // skin-support-design material design 控件支持[可选]
    implementation 'skin.support:skin-support-cardview:4.0.5'          // skin-support-cardview CardView 控件支持[可选]
    implementation 'skin.support:skin-support-constraint-layout:4.0.5' // skin-support-constraint-layout ConstraintLayout 控件支持[可选]
初始化
     //初始化换肤框架
	 SkinCompatManager.withoutActivity(this)
          //添加各类控件的拦截器
          .addInflater(new SkinAppCompatViewInflater())
          .addInflater(new SkinConstraintViewInflater())
          .addInflater(new SkinCardViewInflater())
          .addInflater(new SkinMaterialViewInflater());

  //Activity中重写下面方法,可以放到BaseActivity中
  @NonNull
  @Override
  public AppCompatDelegate getDelegate() {
    return SkinAppCompatDelegateImpl.get(this, this);
  }

withoutActivity 方法

public static SkinCompatManager withoutActivity(Application application) {
    init(application);
    SkinActivityLifecycle.init(application);
    return sInstance;
}

重点看 SkinActivityLifecycle.init 方法,主要做了两件事情:

  1. 注册Activity生命周期回调,
  2. 替换系统的mFactory2,控制View的创建
public static SkinActivityLifecycle init(Application application) {
    if (sInstance == null) {
        synchronized (SkinActivityLifecycle.class) {
            if (sInstance == null) {
                sInstance = new SkinActivityLifecycle(application);
            }
        }
    }
    return sInstance;
}

private SkinActivityLifecycle(Application application) {
    //注册Activity生命周期回调
    application.registerActivityLifecycleCallbacks(this);
    //替换系统的mFactory2,控制View的创建
    installLayoutFactory(application);
    SkinCompatManager.getInstance().addObserver(getObserver(application));
}

private void installLayoutFactory(Context context) {
    try {
        LayoutInflater layoutInflater = LayoutInflater.from(context);
        LayoutInflaterCompat.setFactory2(layoutInflater, getSkinDelegate(context));
    } catch (Throwable e) {
        Slog.i("SkinActivity", "A factory has already been set on this LayoutInflater");
    }
}

getSkinDelegate(context) 方法,初始化并返回 SkinCompatDelegate 对象

    private SkinCompatDelegate getSkinDelegate(Context context) {
        if (mSkinDelegateMap == null) {
            mSkinDelegateMap = new WeakHashMap<>();
        }

        SkinCompatDelegate mSkinDelegate = mSkinDelegateMap.get(context);
        if (mSkinDelegate == null) {
            mSkinDelegate = SkinCompatDelegate.create(context);
            mSkinDelegateMap.put(context, mSkinDelegate);
        }
        return mSkinDelegate;
    }

SkinCompatDelegate 实现了LayoutInflater.Factory2 接口,重写 onCreateView 方法,从而控制所有View的创建

@Override
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
    //创建View
    View view = createView(parent, name, context, attrs);

    if (view == null) {
        return null;
    }
    //保存到列表中
    if (view instanceof SkinCompatSupportable) {
        mSkinHelpers.add(new WeakReference<>((SkinCompatSupportable) view));
    }

    return view;
}

    public View createView(View parent, final String name, @NonNull Context context,
                           @NonNull AttributeSet attrs) {
        if (mSkinCompatViewInflater == null) {
            mSkinCompatViewInflater = new SkinCompatViewInflater();
        }

       	......
        return mSkinCompatViewInflater.createView(parent, name, context, attrs);
    }

    public final View createView(View parent, final String name, @NonNull Context context, @NonNull AttributeSet attrs) {
        View view = createViewFromHackInflater(context, name, attrs);

        if (view == null) {
            //通过初始化的各个拦截器,根据控件名称创建对应的控件
            view = createViewFromInflater(context, name, attrs);
        }

        if (view == null) {
            //如果没有找到,就类似系统的实现方法
            view = createViewFromTag(context, name, attrs);
        }

        if (view != null) {
            // If we have created a view, check it's android:onClick
            checkOnClickListener(view, attrs);
        }

        return view;
    }

这里的实现有些类似OkHttp的拦截器,View的创建会经过多个 “ViewInflater”,这边选一个拦截器 SkinConstraintViewInflater 看下内部是怎么实现的。

public class SkinConstraintViewInflater implements SkinLayoutInflater {
    @Override
    public View createView(Context context, final String name, AttributeSet attrs) {
        View view = null;
        switch (name) {
            case "androidx.constraintlayout.widget.ConstraintLayout":
                view = new SkinCompatConstraintLayout(context, attrs);
                break;
            default:
                break;
        }
        return view;
    }
}

代码很简单,就是通过控件名称创建自己的 SkinCompatConstraintLayout 换肤控件,

public class SkinCompatConstraintLayout extends ConstraintLayout implements SkinCompatSupportable {
    private final SkinCompatBackgroundHelper mBackgroundTintHelper;
	......
    public SkinCompatConstraintLayout(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        mBackgroundTintHelper = new SkinCompatBackgroundHelper(this);
        mBackgroundTintHelper.loadFromAttributes(attrs, defStyleAttr);
    }

    @Override
    public void setBackgroundResource(int resId) {
        super.setBackgroundResource(resId);
        if (mBackgroundTintHelper != null) {
            mBackgroundTintHelper.onSetBackgroundResource(resId);
        }
    }

    //收到换肤事件,
    @Override
    public void applySkin() {
        if (mBackgroundTintHelper != null) {
            mBackgroundTintHelper.applySkin();
        }
    }
}

SkinCompatConstraintLayout 实现 SkinCompatSupportable接口,触发换肤时会调用applySkin方法替换控件的背景。

加载皮肤包
 //加载皮肤包
 SkinCompatManager.getInstance().loadSkin(DARK_SKIN_NAME, new SkinLoaderListener() {
  @Override
  public void onStart() {
    
  }

  @Override
  public void onSuccess() {

  }

  @Override
  public void onFailed(String errMsg) {

  }
}, SKIN_LOADER_STRATEGY_ASSETS);

    /**
     * 加载皮肤包.
     *
     * @param skinName 皮肤包名称.
     * @param listener 皮肤包加载监听.
     * @param strategy 皮肤包加载策略.SKIN_LOADER_STRATEGY_ASSETS从assets目录加载皮肤包
     */
loadSkin(String skinName, SkinLoaderListener listener, int strategy)

重点看下loadSkin方法

public AsyncTask loadSkin(String skinName, SkinLoaderListener listener, int strategy) {
    SkinLoaderStrategy loaderStrategy = mStrategyMap.get(strategy);
    if (loaderStrategy == null) {
        return null;
    }
    return new SkinLoadTask(listener, loaderStrategy).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, skinName);
}

使用AsynTask进行异步操作,加载皮肤包

private class SkinLoadTask extends AsyncTask<String, Void, String> {
    private final SkinLoaderListener mListener;
    private final SkinLoaderStrategy mStrategy;

    SkinLoadTask(@Nullable SkinLoaderListener listener, @NonNull SkinLoaderStrategy strategy) {
        mListener = listener;
        mStrategy = strategy;
    }

    @Override
    protected void onPreExecute() {
        if (mListener != null) {
            mListener.onStart();
        }
    }

    @Override
    protected String doInBackground(String... params) {
        synchronized (mLock) {
            while (mLoading) {
                try {
                    mLock.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            mLoading = true;
        }
        try {
            if (params.length == 1) {
                String skinName = mStrategy.loadSkinInBackground(mAppContext, params[0]);
                if (TextUtils.isEmpty(skinName)) {
                    SkinCompatResources.getInstance().reset(mStrategy);
                    return "";
                }
                return params[0];
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        SkinCompatResources.getInstance().reset();
        return null;
    }

    @Override
    protected void onPostExecute(String skinName) {
        synchronized (mLock) {
            // skinName 为""时,恢复默认皮肤
            if (skinName != null) {
                SkinPreference.getInstance().setSkinName(skinName).setSkinStrategy(mStrategy.getType()).commitEditor();
                notifyUpdateSkin();
                if (mListener != null) {
                    mListener.onSuccess();
                }
            } else {
                SkinPreference.getInstance().setSkinName("").setSkinStrategy(SKIN_LOADER_STRATEGY_NONE).commitEditor();
                if (mListener != null) {
                    mListener.onFailed("皮肤资源获取失败");
                }
            }
            mLoading = false;
            mLock.notifyAll();
        }
    }
}

doInBackground 通过皮肤包名称获取皮肤包资源,notifyUpdateSkin 通知所有 SkinCompatSupportable 对象更新皮肤

创建皮肤包
  1. 新建一个Application Module
  2. 创建对应皮肤的颜色等资源
  3. 资源名称和原工程一样,颜色值修改成对应皮肤包色值
  4. 打apk包,改为 .skin 文件,放在原工程的 assets目录下
    在这里插入图片描述
    在这里插入图片描述

实现效果:

浅色模式
在这里插入图片描述

浅色模式色值
在这里插入图片描述

点击深色模式按钮切换深色模式
在这里插入图片描述

皮肤包的色值
在这里插入图片描述

其他使用
  • 恢复默认样式
SkinCompatManager.getInstance().restoreDefaultTheme();
  • setBackgroundColor、setBackground、setTextColor等方法失效问题

因为框架是根据资源ID,如R.color.black,找到皮肤包中对应名称的资源,所以如果代码中直接设置颜色或者图片,是不支持换肤的。可以改为setBackgroundResource

  • 适配Dialog

SkinXXXView 创建时会获取控件设置的颜色(textColor)、背景(background)等属性的ID,然后再根据ID加载皮肤包中对应的资源。

但是系统的Dialog的布局控件并没有设置背景属性 background等,所以默认情况下Dialog是不支持换肤的。

Skin-Support的解决方法就是替换系统默认的Dialog布局,然后再设置对应的颜色等属性,最终实现换肤效果。

在styles.xml中做如下的声明:

    <item name="alertDialogStyle">@style/AlertDialog.SkinCompat</item>
    <!-- 以下属性需要用户自行实现,并在皮肤包中提供对应值 -->
    <item name="skinAlertDialogBackground"></item>
    <item name="skinAlertDialogTitleTextColor"></item>
    <item name="skinAlertDialogMessageTextColor"></item>
    <item name="skinAlertDialogNeutralButtonTextColor"></item>
    <item name="skinAlertDialogNegativeButtonTextColor"></item>
    <item name="skinAlertDialogPositiveButtonTextColor"></item>
    <item name="skinAlertDialogControlHighlightColor"></item>
    <item name="skinAlertDialogListDivider"></item>
    <item name="skinAlertDialogListItemTextColor"></item>
</style>
  • 自定义控件换肤

Skin-Support 只提供系统组件的换肤,自定义控件需要实现 SkinCompatSupportable 接口或者直接继承 SkinXXXView,然后获取布局中属性的ID和重写 applySkin 方法。

public class SkinSwitchButton extends SwitchButton implements SkinCompatSupportable {

  private static final String TAG = "SkinSwitchButton";

  private int kswThumbDrawable;
  private int kswBackDrawable;

  public SkinSwitchButton(Context context, AttributeSet attrs,
      int defStyle) {
    super(context, attrs, defStyle);
    //获取SwitchButton的属性ID
    TypedArray ta = attrs == null ? null : context.obtainStyledAttributes(attrs, R.styleable.SwitchButton);
    if (ta != null) {
      kswThumbDrawable = ta.getResourceId(R.styleable.SwitchButton_kswThumbDrawable, INVALID_ID);
      kswBackDrawable = ta.getResourceId(R.styleable.SwitchButton_kswBackDrawable, INVALID_ID);
      ta.recycle();
    }
    applySkin();
  }

  public SkinSwitchButton(Context context, AttributeSet attrs) {
    this(context, attrs, 0);
  }

  public SkinSwitchButton(Context context) {
    this(context, null, 0);
  }

  @Override
  public void applySkin() {
	//通过ID获取皮肤包对应的资源
    kswThumbDrawable = SkinCompatHelper.checkResourceId(kswThumbDrawable);
    if (kswThumbDrawable != INVALID_ID) {
      setThumbDrawable(SkinCompatResources.getDrawable(getContext(), kswThumbDrawable));
    }

    kswBackDrawable = SkinCompatHelper.checkResourceId(kswBackDrawable);
    if (kswBackDrawable != INVALID_ID) {
      setBackDrawable(SkinCompatResources.getDrawable(getContext(), kswBackDrawable));
    }
  }
}
动态修改颜色

Skin-Support 也支持代码中动态修改颜色,且优先级最高

//动态修改颜色
SkinCompatUserThemeManager.get().addColorState(colorId, newColor);
//动态修改图片
SkinCompatUserThemeManager.get().addDrawablePath(int drawableRes, String drawablePath);
//颜色修改完后需要调用该方法才可以生效
SkinCompatUserThemeManager.get().apply();

//清除自定义的颜色和图片
SkinCompatUserThemeManager.get().clearColors();
SkinCompatUserThemeManager.get().clearDrawables();

参考:

https://www.jianshu.com/p/2c3833b8a1d2?utm_campaign=hugo

https://blog.csdn.net/c10WTiybQ1Ye3/article/details/119223672

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值