• Android可更换布局的换肤方案


    换肤,顾名思义,就是对应用中的视觉元素进行更新,呈现新的显示效果。一般来说,换肤的时候只是更新UI上使用的资源,如颜色,图片,字体等等。本文介绍一种笔者自己使用的基于布局的Android换肤方案,不仅可以更换所有的UI资源,而且可以更换主题样式(style)和布局样式。代码已托管到github:SkinFramework

    换肤当然得有相应的皮肤包,不管是内置在应用内,还是做成可安装的皮肤应用包。但是这两种都有弊端:

    1.内置在应用内会增加应用包的体积。

    2.皮肤安装包需要安装过程,会占用更多的设备内置存储,用户会介意安装过多应用。而且为了是应用能够访问安装包内的资源,必须与应用使用相同的shareUserId。

    鉴于此,本文推荐使用无需安装的外置皮肤包,优点在于:

    1.无需安装,也无关乎shareUserId,不会引起用户反感。

    2.按需下载使用,用户需要使用时自行下载,下载即可使用。

    3.可放置于任何可访问的位置,SD卡或内置存储,可随时删除和添加,不会增加应用体积。

    先来看一下效果图:

    可以看到,图中有三种皮肤,默认皮肤,plain皮肤和vivid皮肤,都是更换了布局和资源的,其中还使用了AdapterView和Fragment作测试。可以看到,不同的皮肤有不同的布局样式,布局样式的不同也带来了很多可能,如隐藏或移动了功能入口。

    所以说这是一个有很多可能的换肤框架,下面介绍一下核心 实现。

    一、皮肤包

    皮肤包就是一个不包含代码文件的Apk包,无需安装,可以新建工程,删除掉代码文件,复制应用里面需要修改的资源到新工程中修改,打成新包即可作为皮肤包使用,皮肤包后缀名可以改为任意。示例中使用了.skin作为后缀名。

    二、皮肤包加载

    皮肤包中包含的资源文件,需要加载到AssetManager中并创建Resources才能提供使用,关于Android的资源管理机制书上或网上已经有很多介绍,可以参考:Android中资源管理机制详细分析。所以我们的第一件事也是来加载皮肤包:

     AssetManager assetManager = AssetManager.class.newInstance();
            Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
            //Return 0 on failure.
            Object ret = addAssetPath.invoke(assetManager, skinPath);
            if (Integer.parseInt(ret.toString()) == 0) {
                throw new IllegalStateException("Add asset fail");
            }
            Resources localRes = context.getResources();
            return new SkinResource(context, assetManager, localRes.getDisplayMetrics(), localRes.getConfiguration(), packageName);

    三、资源管理器

    加载了皮肤包,我们就有了两套可共使用的皮肤资源,应用默认资源和皮肤包资源,何时使用默认,何时使用皮肤,需要有一个管理器来决定,所以我们实现一个名为ComposedResources的类来扮演ResourcesManager:

    /**
     * Created by ARES on 2016/5/20
     * This is a resources class consists of App default skin and external skin resources if exists. We will find resource in external skin resources first,then the default.
     * Assume all resources ids are original  so that we should find corresponding resources ids in skin .
     */
    
    public class ComposedResources extends BaseResources {
        static int LAYOUT_TAG_ID = -1;
        private Context mContext;
        private BaseSkinResources mSkinResources;
    
        public ComposedResources(Context context) {
            this(context, null);
        }
    
        public ComposedResources(Context context, BaseSkinResources skinResources) {
            super(context.getResources());
            mContext = context;
            mSkinResources = skinResources;
        }
    
    
        public ComposedResources setSkinResources(BaseSkinResources resources) {
            mSkinResources = resources;
            return this;
        }
    
        public BaseSkinResources getSkinResources() {
            return mSkinResources;
        }
    
        @NonNull
        @Override
        public CharSequence getText(@StringRes int id) throws NotFoundException {
            int realId = getCorrespondResId(id);
            if (realId > 0) {
                try {
                    return mSkinResources.getText(realId);
                } catch (Exception e) {
                }
            }
            return super.getText(id);
        }
    
        @NonNull
        @Override
        public CharSequence getQuantityText(@PluralsRes int id, int quantity) throws NotFoundException {
            int realId = getCorrespondResId(id);
            if (realId > 0) {
                return mSkinResources.getQuantityText(realId, quantity);
            }
            return super.getQuantityText(id, quantity);
        }
    
        @NonNull
        @Override
        public String getQuantityString(@PluralsRes int id, int quantity, Object... formatArgs) throws NotFoundException {
            int realId = getCorrespondResId(id);
            if (realId > 0) {
                return mSkinResources.getQuantityString(realId, quantity, formatArgs);
            }
            return super.getQuantityString(id, quantity, formatArgs);
        }
    
        @NonNull
        @Override
        public String getQuantityString(@PluralsRes int id, int quantity) throws NotFoundException {
            int realId = getCorrespondResId(id);
            if (realId > 0) {
                return mSkinResources.getQuantityString(realId, quantity);
            }
            return super.getQuantityString(id, quantity);
        }
    
        @Override
        public CharSequence getText(@StringRes int id, CharSequence def) {
            int realId = getCorrespondResId(id);
            if (realId > 0) {
                return mSkinResources.getText(realId, def);
            }
            return super.getText(id, def);
        }
    
        @NonNull
        @Override
        public CharSequence[] getTextArray(@ArrayRes int id) throws NotFoundException {
            int realId = getCorrespondResId(id);
            if (realId > 0) {
                return mSkinResources.getTextArray(id);
            }
            return super.getTextArray(id);
        }
    
        @NonNull
        @Override
        public String[] getStringArray(@ArrayRes int id) throws NotFoundException {
            int realId = getCorrespondResId(id);
            if (realId > 0) {
                return mSkinResources.getStringArray(realId);
            }
            return super.getStringArray(id);
        }
    
        @NonNull
        @Override
        public int[] getIntArray(@ArrayRes int id) throws NotFoundException {
            int realId = getCorrespondResId(id);
            if (realId > 0) {
                return mSkinResources.getIntArray(realId);
            }
            return super.getIntArray(id);
        }
    
        @NonNull
        @Override
        public TypedArray obtainTypedArray(@ArrayRes int id) throws NotFoundException {
            int realId = getCorrespondResId(id);
            if (realId > 0) {
                return mSkinResources.obtainTypedArray(realId);
            }
            return super.obtainTypedArray(id);
        }
    
        @Override
        public float getDimension(@DimenRes int id) throws NotFoundException {
            int realId = getCorrespondResId(id);
            if (realId > 0) {
                return mSkinResources.getDimension(realId);
            }
            return super.getDimension(id);
        }
    
        @Override
        public int getDimensionPixelOffset(@DimenRes int id) throws NotFoundException {
            int realId = getCorrespondResId(id);
            if (realId > 0) {
                return mSkinResources.getDimensionPixelOffset(realId);
            }
            return super.getDimensionPixelOffset(id);
        }
    
        @Override
        public int getDimensionPixelSize(@DimenRes int id) throws NotFoundException {
            int realId = getCorrespondResId(id);
            if (realId > 0) {
                return mSkinResources.getDimensionPixelSize(realId);
            }
            return super.getDimensionPixelSize(id);
        }
    
        @Override
        public float getFraction(@FractionRes int id, int base, int pbase) {
            int realId = getCorrespondResId(id);
            if (realId > 0) {
                return mSkinResources.getFraction(id, base, pbase);
            }
            return super.getFraction(id, base, pbase);
        }
    
        @Override
        public Drawable getDrawable(@DrawableRes int id) throws NotFoundException {
            int realId = getCorrespondResId(id);
            if (realId > 0) {
                return mSkinResources.getDrawable(realId);
            }
            return super.getDrawable(id);
        }
    
        @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
        @Override
        public Drawable getDrawable(@DrawableRes int id, @Nullable Theme theme) throws NotFoundException {
            int realId = getCorrespondResId(id);
            if (realId > 0) {
                return mSkinResources.getDrawable(realId, theme);
            }
            return super.getDrawable(id, theme);
        }
    
        @Override
        public Drawable getDrawableForDensity(@DrawableRes int id, int density) throws NotFoundException {
            int realId = getCorrespondResId(id);
            if (realId > 0) {
                return mSkinResources.getDrawableForDensity(realId, density);
            }
            return super.getDrawableForDensity(id, density);
        }
    
        @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
        @Override
        public Drawable getDrawableForDensity(@DrawableRes int id, int density, @Nullable Theme theme) {
            int realId = getCorrespondResId(id);
            if (realId > 0) {
                return mSkinResources.getDrawableForDensity(realId, density, theme);
            }
            return super.getDrawableForDensity(id, density, theme);
        }
    
        @Override
        public Movie getMovie(@RawRes int id) throws NotFoundException {
            int realId = getCorrespondResId(id);
            if (realId > 0) {
                return mSkinResources.getMovie(realId);
            }
            return super.getMovie(id);
        }
    
        @Override
        public int getColor(@ColorRes int id) throws NotFoundException {
            int realId = getCorrespondResId(id);
            if (realId > 0) {
                return mSkinResources.getColor(realId);
            }
            return super.getColor(id);
        }
    
        @RequiresApi(api = Build.VERSION_CODES.M)
        @Override
        public int getColor(@ColorRes int id, @Nullable Theme theme) throws NotFoundException {
            int realId = getCorrespondResId(id);
            if (realId > 0) {
                return mSkinResources.getColor(realId, theme);
            }
            return super.getColor(id, theme);
        }
    
        @Nullable
        @Override
        public ColorStateList getColorStateList(@ColorRes int id) throws NotFoundException {
            int realId = getCorrespondResId(id);
            if (realId > 0) {
                return mSkinResources.getColorStateList(realId);
            }
            return super.getColorStateList(id);
        }
    
        @RequiresApi(api = Build.VERSION_CODES.M)
        @Nullable
        @Override
        public ColorStateList getColorStateList(@ColorRes int id, @Nullable Theme theme) throws NotFoundException {
            int realId = getCorrespondResId(id);
            if (realId > 0) {
                return mSkinResources.getColorStateList(realId, theme);
            }
            return super.getColorStateList(id, theme);
        }
    
        @Override
        public boolean getBoolean(@BoolRes int id) throws NotFoundException {
            int realId = getCorrespondResId(id);
            if (realId > 0) {
                return mSkinResources.getBoolean(realId);
            }
            return super.getBoolean(id);
        }
    
        @Override
        public int getInteger(@IntegerRes int id) throws NotFoundException {
            int realId = getCorrespondResId(id);
            if (realId > 0) {
                return mSkinResources.getInteger(realId);
            }
            return super.getInteger(id);
        }
    
        @Override
        public XmlResourceParser getLayout(@LayoutRes int id) throws NotFoundException {
            int realId = getCorrespondResIdStrictly(id);
            if (realId > 0) {
                return mSkinResources.getLayout(realId);
            }
            return super.getLayout(id);
        }
    
        @Override
        public XmlResourceParser getAnimation(@AnimRes int id) throws NotFoundException {
            int realId = getCorrespondResIdStrictly(id);
            if (realId > 0) {
                return mSkinResources.getAnimation(realId);
            }
            return super.getAnimation(id);
        }
    
        @Override
        public XmlResourceParser getXml(@XmlRes int id) throws NotFoundException {
            int realId = getCorrespondResIdStrictly(id);
            if (realId > 0) {
                return mSkinResources.getXml(realId);
            }
            return super.getXml(id);
        }
    
        @Override
        public InputStream openRawResource(@RawRes int id) throws NotFoundException {
            int realId = getCorrespondResId(id);
            if (realId > 0) {
                return mSkinResources.openRawResource(realId);
            }
            return super.openRawResource(id);
        }
    
        @Override
        public InputStream openRawResource(@RawRes int id, TypedValue value) throws NotFoundException {
            int realId = getCorrespondResId(id);
            if (realId > 0) {
                return mSkinResources.openRawResource(realId, value);
            }
            return super.openRawResource(id, value);
        }
    
        @Override
        public AssetFileDescriptor openRawResourceFd(@RawRes int id) throws NotFoundException {
            int realId = getCorrespondResId(id);
            if (realId > 0) {
                return mSkinResources.openRawResourceFd(realId);
            }
            return super.openRawResourceFd(id);
        }
    
        @Override
        public void getValue(@AnyRes int id, TypedValue outValue, boolean resolveRefs) throws NotFoundException {
            int realId = getCorrespondResId(id);
            if (realId > 0) {
                mSkinResources.getValue(realId, outValue, resolveRefs);
                return;
            }
            super.getValue(id, outValue, resolveRefs);
        }
    
        @Override
        public void getValueForDensity(@AnyRes int id, int density, TypedValue outValue, boolean resolveRefs) throws NotFoundException {
            int realId = getCorrespondResId(id);
            if (realId > 0) {
                mSkinResources.getValueForDensity(realId, density, outValue, resolveRefs);
                return;
            }
            super.getValueForDensity(id, density, outValue, resolveRefs);
        }
    
        @Override
        public void getValue(String name, TypedValue outValue, boolean resolveRefs) throws NotFoundException {
            if (mSkinResources != null) {
                try {
                    mSkinResources.getValue(name, outValue, resolveRefs);
                    return;
                } catch (Exception e) {
                }
            }
            super.getValue(name, outValue, resolveRefs);
        }
    
        @Override
        public void updateConfiguration(Configuration config, DisplayMetrics metrics) {
            if (mSkinResources != null) {
                mSkinResources.updateConfiguration(config, metrics);
            }
            super.updateConfiguration(config, metrics);
        }
    
        /**
         * Get correspond resources id with  app package. See also {@link #getCorrespondResId(int)}
         *
         * @param resId
         * @return 0 if not exist
         */
        public int getCorrespondResIdStrictly(int resId) {
            if (mSkinResources == null) {
                return 0;
            }
            String resName = getResourceName(resId);
            return mSkinResources.getIdentifier(resName, null, null);
        }
    
        /**
         * Get correspond resources id with skin package. See also {@link #getCorrespondResId(int)}
         *
         * @param resId
         * @return
         */
        public int getCorrespondResId(int resId) {
            if (mSkinResources == null) {
                return 0;
            }
            return mSkinResources.getCorrespondResId(resId);
        }
    
    
        @Override
        public View getView(Context context, @LayoutRes int resId) {
            //Take a resource id as the tag key.
            if (LAYOUT_TAG_ID < 1) {
                LAYOUT_TAG_ID = resId;
            }
            View view;
            if (mSkinResources != null) {
                int realId = getCorrespondResId(resId);
                if (realId > 0) {
                    view = mSkinResources.getView(context, realId);
                    if (view != null) {
                        view.setTag(LAYOUT_TAG_ID, mSkinResources.getPackageName());
                        SkinUtils.showIds(view);
                        return view;
                    }
                }
            }
            view = LayoutInflater.from(context).inflate(resId, null);
            view.setTag(LAYOUT_TAG_ID, getPackageName());
            SkinUtils.showIds(view);
            return view;
        }
    
        @Override
        public String getPackageName() {
            return mContext.getPackageName();
        }
    }

    可以看到这个ResourceManager本身也是一个Resources,它继承自BaseResources,BaseResources继承自android.content.res.Resources。所以它可以直接作为应用的Resources来使用。其中有几点需要注意:

    1.查找资源时,资源管理器应优先查找皮肤包中的资源,若皮肤包中没有相应资源,才使用应用默认资源。

    2.每个应用包中的资源id是不同的,查找资源时,我们传入Resources的id都是应用中的id,而非皮肤包中的id,所以我们需要转换为皮肤包中相应的资源id,再获取具体的资源(此代码实现在SkinResources中,ComposedResources调用了此方法):

       /**
         * Get correspond resource id in skin archive.
         * @param resId Resource id in app.
         * @return 0 if not exist
         */
        public int getCorrespondResId(int resId) {
            Resources appResources = getAppResources();
            String resName = appResources.getResourceName(resId);
            if (!TextUtils.isEmpty(resName)) {
                String skinName = resName.replace(mAppPackageName, getPackageName());
                int id = getIdentifier(skinName, null, null);
                return id;
            }
            return 0;
        }

    3.在获取XmlResourceParser时,需要使用应用对于资源的描述,而非皮肤包中的资源描述,所以有了getCorrespondResIdStrictly:

      /**
         * Get correspond resources id with  app package. See also {@link #getCorrespondResId(int)}
         *
         * @param resId
         * @return 0 if not exist
         */
        public int getCorrespondResIdStrictly(int resId) {
            if (mSkinResources == null) {
                return 0;
            }
            String resName = getResourceName(resId);
            return mSkinResources.getIdentifier(resName, null, null);
        }

    4.我们使用了LAYOUT_TAG_ID来记录了Layout所属的皮肤包,以便可以动态的判断是否需要更换布局(此方法可以用在动态换肤的时候,详情参考Demo):

     @Override
        public View getView(Context context, @LayoutRes int resId) {
            //Take a resource id as the tag key.
            if (LAYOUT_TAG_ID < 1) {
                LAYOUT_TAG_ID = resId;
            }
            View view;
            if (mSkinResources != null) {
                int realId = getCorrespondResId(resId);
                if (realId > 0) {
                    view = mSkinResources.getView(context, realId);
                    if (view != null) {
                        view.setTag(LAYOUT_TAG_ID, mSkinResources.getPackageName());
                        return view;
                    }
                }
            }
            view = LayoutInflater.from(context).inflate(resId, null);
            view.setTag(LAYOUT_TAG_ID, getPackageName());
            return view;
        }

    四、布局更换处理

    通过上述的代码,我们就已经能够完成常见资源的换肤了。但是对于布局资源,我们还需要做额外的处理。

    1.Context与LayoutInflater

    渲染View时,我们需要使用皮肤对应的Context和LayoutInflater,这样才能在View中使用正确的资源,所以我们为外置皮肤包创建相应的Context:

      /**
         * Context implementation for skin package.
         */
        private class SkinThemeContext extends ContextThemeWrapper {
            private WeakReference<Context> mContextRef;
    
            public SkinThemeContext(Context base) {
                super();
                if (base instanceof ContextThemeWrapper) {
                    attachBaseContext(((ContextThemeWrapper) base).getBaseContext());
                    mContextRef = new WeakReference<Context>(base);
                } else {
                    attachBaseContext(base);
                }
                int themeRes = getThemeRes();
                if (themeRes <= 0) {
                    themeRes = android.R.style.Theme_Light;
                }
                setTheme(themeRes);
            }
    
            /**
             * This implementation will support <code>onClick</code> attribute of view in xml.
             * @param v
             */
            public void onClick(View v) {
                Context context = mContextRef == null ? null : mContextRef.get();
                if (context == null) {
                    return;
                }
                if (context instanceof View.OnClickListener) {
                    ((View.OnClickListener) context).onClick(v);
                } else {
                    Class cls = context.getClass();
                    try {
                        Method m = cls.getDeclaredMethod("onClick", View.class);
                        if (m != null) {
                            m.invoke(context, v);
                        }
                    } catch (NoSuchMethodException e) {
                        e.printStackTrace();
                    } catch (InvocationTargetException e) {
                        e.printStackTrace();
                    } catch (IllegalAccessException e) {
                        e.printStackTrace();
                    }
                }
            }
    
            @Override
            public AssetManager getAssets() {
                return getAssets();
            }
    
            @Override
            public Resources getResources() {
                return SkinResource.this;
            }
    
            private int getThemeRes() {
                try {
                    Method m = Context.class.getMethod("getThemeResId");
                    return (int) m.invoke(getBaseContext());
                } catch (Exception e) {
                    e.printStackTrace();
                }
                return -1;
            }
    
        }

    其中我们对xml布局文件中的onClick属性作了支持,同时通过提供了皮肤包对应的资源,然后我们使用这个Context的实例创建LayoutInflater并渲染View:

      @Override
        public View getView(Context context, @LayoutRes int resId) {
            try {
                Context skinContext = new SkinThemeContext(context);
                View v = LayoutInflater.from(skinContext).inflate(resId, null);
                handleView(skinContext, v);
                return v;
            } catch (Exception e) {
    
            }
            return null;
        }

    其中注意到handleView方法,正如前文所说,每个应用包生成的资源id是不一样的,这里View中生成的id是皮肤包中的id,需要转换为应用中的id方可使用:

        /**
         * Handle view to support used by app.
         *
         * @param v View resource from skin package.
         */
        public void handleView(Context context, View v) {
            resetID();
            //Id map: Key as skin id and Value as local id.
            SparseIntArray array = new SparseIntArray();
            buildIdRules(context, v, array);
            int size = array.size();
            // Map ids to which app can recognize locally.
            for (int i = 0; i < size; i++) {
                //Map id defined in skin package into real id in app.
                v.findViewById(array.keyAt(i)).setId(array.valueAt(i));
            }
        }
    
        /**
         * Extract id from view , build id rules and inflate rules if needed.
         *
         * @param v
         * @param array
         */
        protected void buildIdRules(Context context, View v, SparseIntArray array) {
            if (v.getId() != View.NO_ID) {
                //Get mapped id by id name.
                String idName = getResourceEntryName(v.getId());
                int mappedId = getAppResources().getIdentifier(idName, "id", context.getPackageName());
                //Add custom id to avoid id conflict when mapped id not exist.
                //Key as skin id and value as mapped id.
                array.put(v.getId(), mappedId > 0 ? mappedId : generateId());
            }
            if (v instanceof ViewGroup) {
                ViewGroup vp = (ViewGroup) v;
                int childCount = vp.getChildCount();
                for (int i = 0; i < childCount; i++) {
                    buildIdRules(context, vp.getChildAt(i), array);
                }
            }
            buildInflateRules(v, array);
        }
    
        /**
         * Build inflate rules.
         *
         * @param v
         * @param array ID map of which Key as skin id and value as mapped id.
         */
        protected void buildInflateRules(View v, SparseIntArray array) {
            ViewGroup.LayoutParams lp = v.getLayoutParams();
            if (lp == null) {
                return;
            }
            if (lp instanceof RelativeLayout.LayoutParams) {
                int[] rules = ((RelativeLayout.LayoutParams) lp).getRules();
                if (rules == null) {
                    return;
                }
                int size = rules.length;
                int mapRule = -1;
                for (int i = 0; i < size; i++) {
                    //Key as skin id and value as mapped id.
                    if (rules[i] > 0 && (mapRule = array.get(rules[i])) > 0) {
    //                    Log.i(TAG, "Rules[" + i + "]: Mapped from: " + rules[i] + "  to  " +mapRule);
                        rules[i] = mapRule;
                    }
                }
            }
        }

    五、使用

    下载源码,集成skin module到工程中,然后使用SkinManager提供的接口:

    public SkinManager initialize(Context context);//初始化皮肤管理器
    
    
    
    /**
     * Register an observer to be informed of skin changed for ui interface such as activity,fragment, dialog etc.
     * @param observer
     */
    public void register(ISkinObserver observer);//注册换肤监听器,用于需要动态换肤的场景。
    
    /**
     * Get resources.
     * @return
     */
    public BaseResources getResources();//获取资源
    
    /**
     * Change skin.
     * @param skinPath Path of skin archive.
     * @param pkgName Package name of skin archive.
     * @param cb Callback to be informed of skin-changing event.
     */
    public void changeSkin(String skinPath, String pkgName, ISkinCallback cb);//更换皮肤
    
    /**
     * Restore skin to app default skin.
     *
     * @param cb
     */
    public void restoreSkin(ISkinCallback cb) ;//恢复应用默认皮肤
    
    /**
     * Resume skin.Call it on application started.
     *
     * @param cb
     */
    public void resumeSkin(ISkinCallback cb) ;//恢复当前使用的皮肤,应在应用启动界面调用。

    框架支持两种换肤方式:

    1.静态换肤(推荐)

    换肤完成后,关闭掉所有的Activity,然后重新启动主界面。简单方便。

    2.动态换肤

    需要换肤的Activity、Fragment、Dialog实现ISkinObserver, 并通过register(ISkinObserver observer)注册到SkinManager,动态更换布局,详情见Sample代码。

    这种方式需要重新渲染View,绑定数据,在使用Fragment时,还需要在换肤期间detach/attach fragment,使用起来比较麻烦。优点是换肤后可以停留在原来界面。

    两种方式都需要使用SkinManager提供的Resource来获取布局或其他资源。推荐写自己的BaseActivity,重写getResources()返回SkinManager提供的Resources方便使用。小伙伴们根据自己的实际情况来选择具体使用何种方式。

    好了,到这里换肤框架就介绍完了,欢迎关注SkinFramework的最新动态,若有任何建议和意见,欢迎指出!

  • 相关阅读:
    Java中类与类的关系
    谈谈spring
    mybatis和hibernate的区别
    微信小程序文档解读(一)--api提供支持有哪些
    nodejs问题整理--fs.exists无法正确判断文件的问题
    微信小程序-多级联动
    react
    [微信小程序] 终于可以愉快的使用 async/await 啦
    [Node] 逃离回调地狱
    单例模式
  • 原文地址:https://www.cnblogs.com/oxgen/p/7154699.html
Copyright © 2020-2023  润新知