IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> 移动开发 -> Android 插件化换肤 思路及实现 -> 正文阅读

[移动开发]Android 插件化换肤 思路及实现

1. 换肤效果

先看效果,此demo比较简陋,主要实现了颜色、图片、自定义View、字体样式、状态栏换肤等模块
原皮肤换肤后

2. 换肤思路

先说插件化换肤主要思路:一般应用换肤主要都是更换颜色、图片等资源,所以我们首先需要拿到要换肤的资源ID,然后在皮肤包中设置该属性值为想改变的颜色或图片资源,原应用内下载皮肤包,通过代码即可实现换肤。

例如:一个TextView的颜色需要改变,那么我们需要得到该TextView的textColor属性对应的颜色ID值,假设为android:textColor="@color/colorAccent",原应用中colorAccent的值为<color name="colorAccent">#ffffff</color>
在皮肤包中,我们将colorAccent的值修改为任意想改变的值<color name="colorAccent">#569847</color>,打包成APK,通过代码即可实现TextView的颜色的改变。

一个成熟的项目一般都是批量化换肤,我们来一步步实现。

我们知道layout资源加载都是在setContentView中,在资源文件加载之前替换资源实现换肤,阅读源码,重点在框起来的这行代码
setContentVIew
LayoutInflater也就是布局填充器,负责将xml布局加载到页面上,继续深入,进入inflate方法,最终定位到我们的目标方法createViewFromTag中,重点在框起来的部分,factory工厂
工厂
其中,Factory2继承自Factory,比Factory多了一个parent参数。区别在于:如果需要提供将创建视图的父级,则需要使用Factory2 。但如果应用的定位API级别为11+,则通常使用Factory2 ,否则,只需使用Factory

	public interface Factory {
        public View onCreateView(String name, Context context, AttributeSet attrs);
    }

    public interface Factory2 extends Factory {
        public View onCreateView(View parent, String name, Context context, AttributeSet attrs);
    }

Factory2提供了一个接口onCreateView,我们可以通过实现这个接口,介入到创建view的这个过程中去,记录所有的view,同时拿到view所有需要换肤的属性,记录下来,然后根据属性替换,以上就是我们换肤的大致思路。

3. 代码实现

创建一个library,专门用于处理换肤的SDK,先看项目目录如下
换肤库目录

其实SkinManager是换肤库的管理类,单利模式实现,在项目的MyApplication 中初始化,传递application

public class MyApplication extends Application {
    @Override
    public void onCreate() {
        super.onCreate();
        SkinManager.init(this);
    }
}

通过自定义SkinActivityLifeCycle继承自Application.ActivityLifecycleCallbacks,实现对应用中所有Activity的生命周期的管理
skinmanager
SkinActivityLifeCycle主要在onActivityCreated方法中创建自定义工厂SkinFactory,先只看框起来的部分
SkinActivityLifeCycle
SkinFactory继承自LayoutInflater.Factory2,在onCreateView方法中遍历所有的View,得到可以换肤的View的集合
SkinFactory
createViewFromTagcreateView实现如下,代码注释也写得比较清楚,主要思路是用全类名,通过反射获取其class得到该View的构造器,存储到自定义的构造器集合里,便于下次再遇到不用再通过反射创建,然后返回该View的构造器(这段其实和源码一样,不想写可以直接复制源码,这里写出来主要是为了理解思路)

	/**
     * 拿到view
     *
     * @param name         布局控件的名称,如ImageView,LinearLayout等
     * @param attributeSet view的属性集合
     */
    private View createViewFromTag(String name, Context context, AttributeSet attributeSet) {
        // 自定义view
//        if (-1 != name.indexOf(".")) {
        if (name.contains(".")) {
            return null;
        }

        // 原生veiw
        View view = null;
        for (String aMClassPrefixlist : mClassPrefixlist) {
            // mClassPrefixlist[i] + name === android.widget.TextView
            view = createView(aMClassPrefixlist + name, context, attributeSet);
            if (view != null) {
                break;
            }
        }
        return view;
    }


    /**
     * 根据全类名创建view
     * @param name         全类名,如 android.widget.TextView
     * @param attributeSet view的属性集合
     */
    private View createView(String name, Context context, AttributeSet attributeSet) {
        Constructor<? extends View> constructor = constructorHashMap.get(name);
        // 先从集合中取,如果集合中没有存储过该view的构造器,反射获取class,然后获取构造器,再存储到map中
        if (constructor == null) {
            try {
                // 反射,通过全类名获取class
                Class<? extends View> aClass = context.getClassLoader().loadClass(name).asSubclass(View.class);
                // 获取构造参数,只能获取两个参数的构造函数
                constructor = aClass.getConstructor(mConstructorSignature);
                //添加到map中
                constructorHashMap.put(name, constructor);
                // constructor.newInstance()表示根据构造器取到对应的对象
                return constructor.newInstance(context, attributeSet);

            } catch (Exception e) {
                e.printStackTrace();
            }
        } else {
            try {
                return constructor.newInstance(context, attributeSet);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        return null;
    }

继续回到我们实现的onCreateView方法中,既然得到了View,我们就可以通过SkinAttr的load方法,遍历该View种的属性集合,筛选需要换肤的属性,存储起来。
SkinAttr实现如下,主要思路是自定义了一个mAttributes集合,集合中包含了需要换肤的属性合集,在load方法中,通过遍历传进来View的AttributeSet集合,与我们需要换肤的集合比较,如果有就获取该属性对应的属性值,判断属性值开头是#@,等。
如果是#说明是固定颜色值,可以修改也可以不改,具体看项目需求,此处未修改。
代表是系统属性,需要特殊处理,其他就是类似@开头的值,依次得到resId后,创建自定义的SkinPain,包括属性名和属性id,添加到SkinPain的集合中。
遍历完成后,判断SkinPain集合是否为空(代码可以看到,这里的条件还有view instanceof TextView || view instanceof SkinViewSupport,这两个是更换字体和自定义view的判断条件,后面再讲),不为空则创建SkinView(属性包含View和SkinPain),并将其添加到SkinView的集合中,至此我们得到了所有需要换肤的View的集合。

/**
 * 遍历view的属性集合类
 */
public class SkinAttr {
    private Typeface typeface;
    private String tag = SkinAttr.class.getSimpleName();
    // 可更改的view的属性集合
    private static final List<String> mAttributes = new ArrayList<>();

    static {
        mAttributes.add("background");
        mAttributes.add("src");

        mAttributes.add("textColor");
        mAttributes.add("drawableLeft");
        mAttributes.add("drawableTop");
        mAttributes.add("drawableRight");
        mAttributes.add("drawableBottom");

        mAttributes.add("skinTypeface");
    }

    // view的name和id集合
    private List<SkinView> skinViews = new ArrayList<>();

    public SkinAttr(Typeface typeface) {
        this.typeface = typeface;
    }

    /**
     * 加载view的属性集合,遍历得到它可以更换的属性集合
     *
     * @param view
     * @param attributeSet
     */
    public void load(View view, AttributeSet attributeSet) {
        List<SkinPain> skinPains = new ArrayList<>();
        for (int i = 0; i < attributeSet.getAttributeCount(); i++) {
            // 获取属性的名字
            String attributeName = attributeSet.getAttributeName(i);//background
            // 如果当前view的属性集中包含这些属性
            if (mAttributes.contains(attributeName)) {
                // 获取对应的属性值
                String attributeValue = attributeSet.getAttributeValue(i);//取到的是R文件里对应的值,只不过是string
                Log.e(tag, "attributeValue == " + attributeValue);//?2130837582
                if (attributeValue.startsWith("#")) {//#121212
                    continue;// 带#的是写死的,不改//当然也可以修改,看具体项目需求
                }
                int resId;
                if (attributeValue.startsWith("?")) {// ?colorAccent
                    // 提取attributeValue值,去掉第一位的?剩下colorAccent,并转化为id值
                    int attrId = Integer.parseInt(attributeValue.substring(1));
                    Log.e(tag, "attrId == " + attrId);//2130837582
                    resId = SkinThemeUtils.getResId(view.getContext(), new int[]{attrId})[0];
                    Log.e(tag, "resId? == " + resId);
                } else {
                    // @color/colorAccent
                    resId = Integer.parseInt(attributeValue.substring(1));
                    Log.e(tag, "resId@ == " + resId);
                }
                // 保存属性名称和对应id
                if (resId != 0) {
                    SkinPain skinPain = new SkinPain(attributeName, resId);
                    skinPains.add(skinPain);
                }
            }
        }
        if (!skinPains.isEmpty() || view instanceof TextView || view instanceof SkinViewSupport) {
            SkinView skinView = new SkinView(view, skinPains);
            skinView.applySkin(typeface);
            // 保存view和他的可变属性集合,用于后续修改
            skinViews.add(skinView);
        }
    }

    public void setTypeface(Typeface typeface) {
        this.typeface = typeface;
    }


    // view
    // view的名称和id
    private class SkinView {
        View view;
        List<SkinPain> skinPains;

        public SkinView(View view, List<SkinPain> skinPains) {
            this.view = view;
            this.skinPains = skinPains;
        }

        // 循环遍历,应用修改皮肤属性
        public void applySkin(Typeface typeface) {
            applyTypeFace(typeface);
            applySkinSupport();
            for (SkinPain skinPain : skinPains) {
                Drawable left = null, top = null, right = null, bottom = null;
                Log.e(tag, "skinPain == " + skinPain.attributeName);
                switch (skinPain.attributeName) {
                    case "background":
                        Object background = SkinResources.getInstance().getBackground(skinPain.resId);
                        if (background instanceof Integer) {// 颜色值
                            view.setBackgroundColor((Integer) background);
                        } else {//drawable属性值
                            ViewCompat.setBackground(view, (Drawable) background);
                        }
                        break;
                    case "src":
                        background = SkinResources.getInstance().getBackground(skinPain.resId);
                        if (background instanceof Integer) {
                            ((ImageView) view).setImageDrawable(new ColorDrawable((Integer) background));
                        } else {
                            ((ImageView) view).setImageDrawable((Drawable) background);
                        }
                        break;
                    case "textColor":
                        ((TextView) view).setTextColor(SkinResources.getInstance().getColorStateList(skinPain.resId));
                        break;
                    case "drawableLeft":
                        left = SkinResources.getInstance().getDrawable(skinPain.resId);
                        break;
                    case "drawableTop":
                        top = SkinResources.getInstance().getDrawable(skinPain.resId);
                        break;
                    case "drawableRight":
                        right = SkinResources.getInstance().getDrawable(skinPain.resId);
                        break;
                    case "drawableBottom":
                        bottom = SkinResources.getInstance().getDrawable(skinPain.resId);
                        break;
                    case "skinTypeface":
                        applyTypeFace(SkinResources.getInstance().getTypeface(skinPain.resId));
                        break;
                    default:
                        break;
                }
                if (null != left || null != right || null != top || null != bottom) {
                    ((TextView) view).setCompoundDrawablesWithIntrinsicBounds(left, top, right, bottom);
                }

            }
        }

		// 字体换肤
        private void applyTypeFace(Typeface typeface) {
            if (view instanceof TextView) {
                Log.e(tag, "typeface == " + typeface.getStyle());
                ((TextView) view).setTypeface(typeface);
            }

        }

		// 自定义View换肤
        private void applySkinSupport() {
            if (view instanceof SkinViewSupport) {
                Log.e(tag,"applySkinSupport === ");
                ((SkinViewSupport) view).applySkinView();
            }
        }
    }




    /**
     * view属性名和在R文件中对应id的类
     */
    private class SkinPain {
        String attributeName;
        int resId;

        public SkinPain(String attributeName, int resId) {
            this.attributeName = attributeName;
            this.resId = resId;
        }
    }

    // 更换皮肤
    public void applySkin() {
        for (SkinView skinView : skinViews) {
            skinView.applySkin(typeface);
        }
    }
}

通过实现Factory的onCreateView方法,我们在xml加载之前得到了所有需要换肤的View集合,那么怎么实现换肤呢?

SkinManager中的loadSkin方法,判断传过来的皮肤包地址是否为空,空就加载默认皮肤,否则加载给定路径的皮肤,这里我换肤路径写死了,给模拟器对应的路径传了自定义皮肤包的apk进去,一般线上是先下载,然后换肤。

注意:这里因为读取了sd卡,所以需要添加读写权限,否则会空指针异常

<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

通过反射得到PackageInfo,重点在SkinResources.getInstance().applySkin(skinResource, packageName);这行代码,初始化皮肤包资源和包名。然后通过观察者模式通知更新皮肤。

	// 加载皮肤
    public void loadSkin(String skinPath) {
        Log.e(tag,"skinPath == " + skinPath);
        if (TextUtils.isEmpty(skinPath)) {
            // 清空资源管理器,皮肤资源属性
            SkinResources.getInstance().reset();
            // 地址为空,使用默认皮肤
            SkinPreference.getInstance().setSkin("");
        } else {
            try {
                // 反射创建AssetManager与Resource
                AssetManager assetManager = AssetManager.class.newInstance();
                // 资源路径设置目录或压缩包
                Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
                addAssetPath.invoke(assetManager, skinPath);
                Resources appResources = application.getResources();
                // 根据当前的显示与配置(横竖屏、语言等)创建Resources
                Resources skinResource = new Resources(assetManager, appResources.getDisplayMetrics(), appResources.getConfiguration());


                // 获取外部APK(皮肤包)的包名
                PackageInfo packageArchiveInfo = application.getPackageManager().getPackageArchiveInfo(skinPath, PackageManager.GET_ACTIVITIES);
                Log.e(tag,"packageArchiveInfo == "+packageArchiveInfo);
                if(packageArchiveInfo!=null) {
                    String packageName = packageArchiveInfo.packageName;
                    // 初始化皮肤包资源管理器
                    Log.e(tag,"skinResource == " + skinResource);
                    Log.e(tag,"packageName == " + packageName);
                    SkinResources.getInstance().applySkin(skinResource, packageName);

                    // 本地记录
                    SkinPreference.getInstance().setSkin(skinPath);
                } else {
                    Toast.makeText(application,"包信息为null",Toast.LENGTH_SHORT).show();
                }


            } catch (IllegalAccessException e) {
                e.printStackTrace();
            } catch (InstantiationException e) {
                e.printStackTrace();
            } catch (NoSuchMethodException e) {
                e.printStackTrace();
            } catch (InvocationTargetException e) {
                e.printStackTrace();
            }
        }
        // 通知采集的view更新皮肤
        // 被观察者改变 通知所有观察者
        setChanged();
        notifyObservers(null);
    }

SkinFactory的update方法中收到更新消息,skinAttr.applySkin()方法实现换肤

	@Override
    public void update(Observable observable, Object o) {
    	// 状态栏换肤
        SkinThemeUtils.updateStatusBarColor(activity);
        // 字体换肤,此处是用于设置及时生效的
        Typeface typeface = SkinThemeUtils.updateTypeFace(activity);
        skinAttr.setTypeface(typeface);
        // 普通属性换肤
        skinAttr.applySkin();
    }

SkinAttr的applySkin方法,遍历之前得到的需要换肤的skinViews集合,通过applySkin方法,遍历属性合集

	// 更换皮肤
    public void applySkin() {
        for (SkinView skinView : skinViews) {
            skinView.applySkin(typeface);
        }
    }

SkinAttr的SkinView内部类实现

    // view的名称和id
    private class SkinView {
        View view;
        List<SkinPain> skinPains;

        public SkinView(View view, List<SkinPain> skinPains) {
            this.view = view;
            this.skinPains = skinPains;
        }

        // 循环遍历,应用修改皮肤属性
        public void applySkin(Typeface typeface) {
            applyTypeFace(typeface);
            applySkinSupport();
            for (SkinPain skinPain : skinPains) {
                Drawable left = null, top = null, right = null, bottom = null;
                Log.e(tag, "skinPain == " + skinPain.attributeName);
                switch (skinPain.attributeName) {
                    case "background":
                        Object background = SkinResources.getInstance().getBackground(skinPain.resId);
                        if (background instanceof Integer) {// 颜色值
                            view.setBackgroundColor((Integer) background);
                        } else {//drawable属性值
                            ViewCompat.setBackground(view, (Drawable) background);
                        }
                        break;
                    case "src":
                        background = SkinResources.getInstance().getBackground(skinPain.resId);
                        if (background instanceof Integer) {
                            ((ImageView) view).setImageDrawable(new ColorDrawable((Integer) background));
                        } else {
                            ((ImageView) view).setImageDrawable((Drawable) background);
                        }
                        break;
                    case "textColor":
                        ((TextView) view).setTextColor(SkinResources.getInstance().getColorStateList(skinPain.resId));
                        break;
                    case "drawableLeft":
                        left = SkinResources.getInstance().getDrawable(skinPain.resId);
                        break;
                    case "drawableTop":
                        top = SkinResources.getInstance().getDrawable(skinPain.resId);
                        break;
                    case "drawableRight":
                        right = SkinResources.getInstance().getDrawable(skinPain.resId);
                        break;
                    case "drawableBottom":
                        bottom = SkinResources.getInstance().getDrawable(skinPain.resId);
                        break;
                    case "skinTypeface":
                        applyTypeFace(SkinResources.getInstance().getTypeface(skinPain.resId));
                        break;
                    default:
                        break;
                }
                if (null != left || null != right || null != top || null != bottom) {
                    ((TextView) view).setCompoundDrawablesWithIntrinsicBounds(left, top, right, bottom);
                }

            }
        }
		// 字体换肤
        private void applyTypeFace(Typeface typeface) {
            if (view instanceof TextView) {
                Log.e(tag, "typeface == " + typeface.getStyle());
                ((TextView) view).setTypeface(typeface);
            }

        }
		// 自定义View换肤
        private void applySkinSupport() {
            if (view instanceof SkinViewSupport) {
                Log.e(tag,"applySkinSupport === ");
                ((SkinViewSupport) view).applySkinView();
            }
        }
    }

SkinResources是实现换肤的类,大致思路是,如果是默认皮肤,就返回原始包中对应的id值,如果需要换肤就返回mSkinResources也就是通过皮肤包得到的id,然后在SkinAttr的SkinView的applySkin中设置给对应的View即可实现换肤。


public class SkinResources {

    private static SkinResources instance;

    private Resources mSkinResources;
    private String mSkinPkgName;
    private boolean isDefaultSkin = true;

    private Resources mAppResources;
    private String tag = SkinResources.class.getSimpleName();

    private SkinResources(Context context) {
        mAppResources = context.getResources();
    }

    public static void init(Context context) {
        if (instance == null) {
            synchronized (SkinResources.class) {
                if (instance == null) {
                    instance = new SkinResources(context);
                }
            }
        }
    }

    public static SkinResources getInstance() {
        if (instance == null) {
            throw new IllegalStateException("SkinResources 未初始化");
        }
        return instance;
    }

    public void reset() {
        mSkinResources = null;
        mSkinPkgName = "";
        isDefaultSkin = true;
    }

    public void applySkin(Resources resources, String pkgName) {
        mSkinResources = resources;
        mSkinPkgName = pkgName;
        //是否使用默认皮肤
        isDefaultSkin = TextUtils.isEmpty(pkgName) || resources == null;
    }


    // 根据原生app view参数的id,得到name,取到皮肤包中相同name的id
    public int getIdentifier(int resId) {
        if (isDefaultSkin) {
            return resId;
        }
        //在皮肤包中不一定就是 当前程序的 id
        //获取对应id 在当前的名称 colorPrimary
        //R.drawable.ic_launcher
        String resName = mAppResources.getResourceEntryName(resId);//ic_launcher
        Log.e(tag, " resName == " + resName);
        String resType = mAppResources.getResourceTypeName(resId);//drawable
        Log.e(tag, " resType == " + resType);

        int skinId = mSkinResources.getIdentifier(resName, resType, mSkinPkgName);
        Log.e(tag, " skinId == " + skinId);

        return skinId;
    }

    public int getColor(int resId) {
        if (isDefaultSkin) {
            return mAppResources.getColor(resId);
        }
        int skinId = getIdentifier(resId);
        if (skinId == 0) {
            return mAppResources.getColor(resId);
        }
        return mSkinResources.getColor(skinId);
    }

    public ColorStateList getColorStateList(int resId) {
        if (isDefaultSkin) {
            return mAppResources.getColorStateList(resId);
        }
        int skinId = getIdentifier(resId);
        if (skinId == 0) {
            return mAppResources.getColorStateList(resId);
        }
        return mSkinResources.getColorStateList(skinId);
    }

    public Drawable getDrawable(int resId) {
        //如果有皮肤  isDefaultSkin false 没有就是true
        if (isDefaultSkin) {
            return mAppResources.getDrawable(resId);
        }
        int skinId = getIdentifier(resId);
        if (skinId == 0) {
            return mAppResources.getDrawable(resId);
        }
        return mSkinResources.getDrawable(skinId);
    }


    /**
     * 可能是Color 也可能是drawable
     *
     * @return
     */
    public Object getBackground(int resId) {
        String resourceTypeName = mAppResources.getResourceTypeName(resId);

        if (resourceTypeName.equals("color")) {
            return getColor(resId);
        } else {
            // drawable
            return getDrawable(resId);
        }
    }

    public String getString(int resId) {
        try {
            if (isDefaultSkin) {
                Log.e("SkinResources", "mAppResources.getString(resId) == " + mAppResources.getString(resId));
                return mAppResources.getString(resId);
            }
            int skinId = getIdentifier(resId);
            Log.e("SkinResources", "skinId == " + skinId);
            if (skinId == 0) {
                Log.e("SkinResources", "mAppResources.getString(resId) == " + mAppResources.getString(resId));
                return mAppResources.getString(resId);
            }
            Log.e("SkinResources", "mSkinResources.getString(resId) == " + mSkinResources.getString(skinId));
            return mSkinResources.getString(skinId);
        } catch (Resources.NotFoundException e) {

        }
        return null;
    }

    // 获取字体
    public Typeface getTypeface(int resId) {
        String skinTypefacePath = getString(resId);
        Log.e("tag", "typefacepath == " + skinTypefacePath);
        if (TextUtils.isEmpty(skinTypefacePath)) {
            return Typeface.DEFAULT;
        }
        try {
            Typeface typeface;
            if (isDefaultSkin) {
                typeface = Typeface.createFromAsset(mAppResources.getAssets(), skinTypefacePath);
                return typeface;

            }
            typeface = Typeface.createFromAsset(mSkinResources.getAssets(), skinTypefacePath);
            return typeface;
        } catch (RuntimeException e) {
        }
        return Typeface.DEFAULT;
    }
}

最后在项目中添加点击事件实现换肤即可
mainactivity

写得有点啰嗦,但是大致思路和实现方法基本就是这些,不是很难,就是具体项目中皮肤包的实现比较繁琐,需要细心细心再细心。

4. 生成皮肤包

说到这里,说一下怎么实现皮肤包吧,新建一个项目,不需要activity这些,只保留value下的资源,设置需要换肤的属性值,color,图片等,然后Build——Build Bundle(s)/APK(s)——Build APK(s),打包成一个apk就行了
皮肤包目录
打包apk

5. 代码下载地址

https://download.csdn.net/download/mr_hmgo/21351930
代码描述

  移动开发 最新文章
Vue3装载axios和element-ui
android adb cmd
【xcode】Xcode常用快捷键与技巧
Android开发中的线程池使用
Java 和 Android 的 Base64
Android 测试文字编码格式
微信小程序支付
安卓权限记录
知乎之自动养号
【Android Jetpack】DataStore
上一篇文章      下一篇文章      查看所有文章
加:2021-08-20 15:14:29  更:2021-08-20 15:15:01 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年11日历 -2024/11/23 10:04:22-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码