Android实现动态换肤

常用的app中,很多都带有了换肤功能,换肤是为了换资源文件,也就是res下边的资源。

ini 复制代码
<ImageView
    android:layout_width="match_parent"
    android:layout_height="?actionBarSize"
    android:background="@drawable/toolbar" />

<Button
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:onClick="skinSelect"
    android:text="选择皮肤"
    android:textColor="?colorAccent" />

我们换肤,比如像上边的imageView和Button,主要就是要替换他的背景或者Color,这就需要了解资源的加载流程了。

资源的加载流程最后都会走到xml解析:

less 复制代码
public void setContentView:(int resId) {
    ensureSubDecor();
    ViewGroup contentParent = mSubDecor.findViewById(android.R.id.content);
    contentParent.removeAllViews();
    LayoutInflater.from(mContext).inflate(resId, contentParent);
    mAppCompatWindowCallback.getWrapped().onContentChanged();
}

// 递归解析资源
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();
    }
}

最终都会走到这个方法里边android.view.LayoutInflater#createViewFromTag(android.view.View, java.lang.String, android.content.Context, android.util.AttributeSet, boolean):

ini 复制代码
@UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
        boolean ignoreThemeAttr) {
    if (name.equals("view")) {
        name = attrs.getAttributeValue(null, "class");
    }

    // Apply a theme wrapper, if allowed and one is specified.
    if (!ignoreThemeAttr) {
        final TypedArray ta = context.obtainStyledAttributes(attrs, ATTRS_THEME);
        final int themeResId = ta.getResourceId(0, 0);
        if (themeResId != 0) {
            context = new ContextThemeWrapper(context, themeResId);
        }
        ta.recycle();
    }

    try {
        // 这里尝试创建View,我们换肤需要着重关注这里。这是因为tryCreateView利用了mFactory2来创建View,我们可以通过这里来实现自己的逻辑。
        View view = tryCreateView(parent, name, context, attrs);

        if (view == null) {
            final Object lastContext = mConstructorArgs[0];
            mConstructorArgs[0] = context;
            try {
                if (-1 == name.indexOf('.')) {
                    view = onCreateView(context, parent, name, attrs);
                } else {
                    view = createView(context, name, null, attrs);
                }
            } finally {
                mConstructorArgs[0] = lastContext;
            }
        }

        return view;
    } catch (InflateException e) {
        throw e;

    } catch (ClassNotFoundException e) {
        final InflateException ie = new InflateException(
                getParserStateDescription(context, attrs)
                + ": Error inflating class " + name, e);
        ie.setStackTrace(EMPTY_STACK_TRACE);
        throw ie;

    } catch (Exception e) {
        final InflateException ie = new InflateException(
                getParserStateDescription(context, attrs)
                + ": Error inflating class " + name, e);
        ie.setStackTrace(EMPTY_STACK_TRACE);
        throw ie;
    }
}

@UnsupportedAppUsage(trackingBug = 122360734)
@Nullable
public final View tryCreateView(@Nullable View parent, @NonNull String name,
    @NonNull Context context,
    @NonNull AttributeSet attrs) {
    if (name.equals(TAG_1995)) {
        // Let's party like it's 1995!
        return new BlinkLayout(context, attrs);
    }

    View view;
    // 重点关注mFactory2。
    if (mFactory2 != null) {
        view = mFactory2.onCreateView(parent, name, context, attrs);
    } else if (mFactory != null) {
        view = mFactory.onCreateView(name, context, attrs);
    } else {
        view = null;
    }

    if (view == null && mPrivateFactory != null) {
        view = mPrivateFactory.onCreateView(parent, name, context, attrs);
    }

    return view;
}

mFactory2是何时创建的呢?在Activity的onCreate的时候,会调用这个方法:

java 复制代码
@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");
        }
    }
}

之后系统在创建View的时候就会走这段代码了。

less 复制代码
@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 */
    );
}

我们可以看到,最终走到了androidx.appcompat.app.AppCompatViewInflater#createView(android.view.View, java.lang.String, android.content.Context, android.util.AttributeSet, boolean, boolean, boolean, boolean) 这里边会根据不同的View来实现对应的实例化,这也是为什么我们在xml中声明的TextView,会在初始化之后变成 AppCompatTextView。

ini 复制代码
public final View createView(@Nullable View parent, @NonNull final String name,
        @NonNull Context context,
        @NonNull AttributeSet attrs, boolean inheritContext,
        boolean readAndroidTheme, boolean readAppTheme, boolean wrapContext) {
    final Context originalContext = context;

    // We can emulate Lollipop's android:theme attribute propagating down the view hierarchy
    // by using the parent's context
    if (inheritContext && parent != null) {
        context = parent.getContext();
    }
    if (readAndroidTheme || readAppTheme) {
        // We then apply the theme on the context, if specified
        context = themifyContext(context, attrs, readAndroidTheme, readAppTheme);
    }
    if (wrapContext) {
        context = TintContextWrapper.wrap(context);
    }

    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;
        case "Spinner":
            view = createSpinner(context, attrs);
            verifyNotNull(view, name);
            break;
        case "ImageButton":
            view = createImageButton(context, attrs);
            verifyNotNull(view, name);
            break;
        case "CheckBox":
            view = createCheckBox(context, attrs);
            verifyNotNull(view, name);
            break;
        case "RadioButton":
            view = createRadioButton(context, attrs);
            verifyNotNull(view, name);
            break;
        case "CheckedTextView":
            view = createCheckedTextView(context, attrs);
            verifyNotNull(view, name);
            break;
        case "AutoCompleteTextView":
            view = createAutoCompleteTextView(context, attrs);
            verifyNotNull(view, name);
            break;
        case "MultiAutoCompleteTextView":
            view = createMultiAutoCompleteTextView(context, attrs);
            verifyNotNull(view, name);
            break;
        case "RatingBar":
            view = createRatingBar(context, attrs);
            verifyNotNull(view, name);
            break;
        case "SeekBar":
            view = createSeekBar(context, attrs);
            verifyNotNull(view, name);
            break;
        case "ToggleButton":
            view = createToggleButton(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);
    }

    if (view == null && originalContext != context) {
        // If the original context does not equal our themed context, then we need to manually
        // inflate it using the name so that android:theme takes effect.
        view = createViewFromTag(context, name, attrs);
    }

    if (view != null) {
        // If we have created a view, check its android:onClick
        checkOnClickListener(view, attrs);
        backportAccessibilityAttributes(context, view, attrs);
    }

    return view;
}

既然谷歌可以替换掉TextView为AppCompatTextView,是不是我们也可以实现类似的效果?就是把TextView换成我们的TextView。

less 复制代码
LayoutInflater.from(this).setFactory2(new LayoutInflater.Factory2() {
    @Nullable
    @Override
    public View onCreateView(@Nullable View parent, @NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
        // 完成了TextView被Button替换的效果。
        if (TextUtils.equals(name, "TextView")) {
            Button button = new Button(context);
            button.setText("AAAA");
            return button;
        }
        return null;
    }

    @Nullable
    @Override
    public View onCreateView(@NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
        return null;
    }
});

但是需要注意,Factory2只能设置一次,上边的逻辑需要在onCreate的super之前调用。或者通过反射修改mFactorySet的值。

dart 复制代码
public void setFactory2(Factory2 factory) {
    // mFactorySet有值就会抛出异常。
    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);
    }
}

Activity启动过程中资源的加载流程

performLaunchActivity

-->createBaseContextForActivity(r)

--> ContextImpl.createActivityContext()

--> context.setResources

--> createResources

--> ResourceImpl impl = findOrCreateResourcesImplForKeyLocked

--> impl = createResourcesImpl(key)

--> assets = createAssetManager(key);

--> builder.addApkAssets(loadApkAssets(key.mResDir, false)

AssetManager 加载资源-->资源路径-->默认传入的资源路径key.mResDir(app下面的res,我们可以反射改成皮肤包的资源路径----Resources AssetManager皮肤包的)

不能改变原有的资源加载。单独创建一个AssetManager-->专门加载皮肤包的资源,hide的api是可以直接反射的。

换肤的整体思路

(1)知道xml的View怎么解析?

(2)如何拦截系统的创建流程(setFactory2()实现,这样我们就能自己控制对创建的View的操作了);

(3)拦截后怎么做?重写系统创建过程的代码(复制即可);

(4)收集View以及对应的属性(所有的页面都需要);

(5)拿到皮肤包(也就是apk),进行替换;

(6)如何使用?利用插件化技术,只用插件的resource。系统的资源是通过Resource和AssetManager进行的,当然,Resource最终也是走的AssetManager.

我们采用换肤的最终的方式:

--->系统的资源如何加载?Resource、AssetManager

--->通过Hook技术,创建一个AssetManager。不能用同一个,不影响原流程,因为会造成资源ID冲突,专门加载皮肤包的资源。

--->通过反射addAssetPath 放入皮肤包的路径,从而得到 加载皮肤包资源 AssetManager

--->通过app的资源ID--->找到app的资源Name--->皮肤包的资源ID(为什么要这么做?因为我们换肤的时候给的资源的名肯定是一样的,但是这两个资源的ID在编译之后,在app和皮肤包里边一般是不同的,所以我们需要通过这种方式来获取到皮肤包的资源ID然后设置给app)

AssetManager 加载资源-->资源路径-->默认传入的资源路径key.mResDir(app下面的res,改成皮肤包的资源路径----Resources AssetManager皮肤包的)

代码实现

首先我们需要自定义一个 SkinLayoutInflaterFactory 来拦截创建View的流程,因为我们需要记录每一个View的属性,以便后续换肤的时候来对属性进行修改。这里之所以还需要实现Observer,是因为我们换肤之后,需要利用观察者模式通知所有的界面进行更新。因此每一个界面都应该注册一个观察者。

kotlin 复制代码
public class SkinLayoutInflaterFactory implements LayoutInflater.Factory2, Observer {

因此,我们需要定义一个ApplicationActivityLifecycle,用来在Activity创建的时候,将每一个观察者都传递进去,也就是每一个SkinLayoutInflaterFactory,这样每一个界面都相当于是观察者。

java 复制代码
public class ApplicationActivityLifecycle implements Application.ActivityLifecycleCallbacks {

那么被观察者是谁呢?我们定义一个被观察者,在加载新皮肤之后,会通知所有的观察者,也就是所有的页面,去更新。

scss 复制代码
public class SkinManager extends Observable {

    private volatile static SkinManager instance;
    /**
     * Activity生命周期回调
     */
    private ApplicationActivityLifecycle skinActivityLifecycle;
    private Application mContext;

    /**
     * 初始化 必须在Application中先进行初始化
     */
    public static void init(Application application) {
        if (instance == null) {
            synchronized (SkinManager.class) {
                if (instance == null) {
                    instance = new SkinManager(application);
                }
            }
        }
    }

    private SkinManager(Application application) {
        mContext = application;
        // 共享首选项 用于记录当前使用的皮肤
        SkinPreference.init(application);
        // 资源管理类 用于从 app/皮肤 中加载资源
        SkinResources.init(application);
        // 注册Activity生命周期,并设置被观察者
        skinActivityLifecycle = new ApplicationActivityLifecycle(this);
        application.registerActivityLifecycleCallbacks(skinActivityLifecycle);
        // 加载上次使用保存的皮肤
        loadSkin(SkinPreference.getInstance().getSkin());
    }

    public static SkinManager getInstance() {
        return instance;
    }

    /**
     * 记载皮肤并应用
     *
     * @param skinPath 皮肤路径 如果为空则使用默认皮肤
     */
    public void loadSkin(String skinPath) {
        if (TextUtils.isEmpty(skinPath)) {
            // 还原默认皮肤
            SkinPreference.getInstance().reset();
            SkinResources.getInstance().reset();
        } else {
            try {
                // 反射创建AssetManager 与 Resource
                AssetManager assetManager = AssetManager.class.newInstance();
                // 资源路径设置 目录或压缩包
                Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
                addAssetPath.invoke(assetManager, skinPath);

                // 宿主app的 resources;
                Resources appResource = mContext.getResources();
                // 根据当前的设备显示器信息 与 配置(横竖屏、语言等) 创建Resources
                Resources skinResource = new Resources(assetManager, appResource.getDisplayMetrics(), appResource.getConfiguration());
                // 获取外部Apk(皮肤包) 包名
                PackageManager mPm = mContext.getPackageManager();
                PackageInfo info = mPm.getPackageArchiveInfo(skinPath, PackageManager.GET_ACTIVITIES);
                String packageName = info.packageName;
                SkinResources.getInstance().applySkin(skinResource, packageName);
                // 记录路径
                SkinPreference.getInstance().setSkin(skinPath);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        // 通知采集的View 更新皮肤
        // 被观察者改变 通知所有观察者
        setChanged();
        notifyObservers(null);
    }
}

另外,我们还需要一个类,用来解析所有的View的属性解析,同时也提供更新属性的方式,以便确定我们对哪些属性来进行替换,一般的,我们不需要对所有的属性都进行替换:

csharp 复制代码
/**
 * 这里面放了所有要换肤的view所对应的属性
 */
public class SkinAttribute {
    /**
     * 只有这些属性,我们才需要进行替换,其余的不用。
     */
    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");
    }

    /**
     * 记录换肤需要操作的View与属性信息
     */
    private final List<SkinView> mSkinViews = new ArrayList<>();


    /**
     * 记录下一个VIEW身上哪几个属性需要换肤textColor/src,每一个页面的view都需要收集。
     *
     * @param view
     * @param attrs
     */
    public void look(View view, AttributeSet attrs) {
        List<SkinPair> mSkinPars = new ArrayList<>();

        for (int i = 0; i < attrs.getAttributeCount(); i++) {
            //获得属性名  textColor/background
            String attributeName = attrs.getAttributeName(i);
            if (mAttributes.contains(attributeName)) {
                // 获取属性值
                String attributeValue = attrs.getAttributeValue(i);
                // 比如color 以#开头表示写死的颜色 不可用于换肤
                if (attributeValue.startsWith("#")) {
                    continue;
                }
                int resId;
                // 以 ?开头的表示使用 属性
                if (attributeValue.startsWith("?")) {
                    int attrId = Integer.parseInt(attributeValue.substring(1));
                    resId = SkinThemeUtils.getResId(view.getContext(), new int[]{attrId})[0];
                } else {
                    // 正常以 @ 开头
                    resId = Integer.parseInt(attributeValue.substring(1));
                }
                // 保存属性名以及对应的ID,每一个属性都有对应的ID,为后边映射皮肤包ID做准备。
                SkinPair skinPair = new SkinPair(attributeName, resId);
                mSkinPars.add(skinPair);
            }
        }

        if (!mSkinPars.isEmpty() || view instanceof SkinViewSupport) {
            SkinView skinView = new SkinView(view, mSkinPars);
            // 如果选择过皮肤 ,调用 一次 applySkin 加载皮肤的资源
            skinView.applySkin();
            mSkinViews.add(skinView);
        }
    }


    /*
       对所有的view中的所有的属性进行皮肤修改
     */
    public void applySkin() {
        // 第一层循环,找到所有的View
        for (SkinView mSkinView : mSkinViews) {
            mSkinView.applySkin();
        }
    }

    static class SkinView {
        View view;
        //这个View的能被 换肤的属性与它对应的id 集合
        List<SkinPair> skinPairs;

        public SkinView(View view, List<SkinPair> skinPairs) {
            this.view = view;
            this.skinPairs = skinPairs;

        }

        /**
         * 对一个View中的所有的属性进行修改
         */
        public void applySkin() {
            applySkinSupport();
            // 第二层循环,找到所有的view对应的属性,然后进行设置,实现真正的效果更改。
            for (SkinPair skinPair : skinPairs) {
                Drawable left = null, top = null, right = null, bottom = null;
                switch (skinPair.attributeName) {
                    case "background":
                        // 这一行就实现了:通过app的资源ID--->找到app的资源Name--->皮肤包的资源ID
                        Object background = SkinResources.getInstance().getBackground(skinPair.resId);
                        //背景可能是 @color 也可能是 @drawable
                        if (background instanceof Integer) {
                            view.setBackgroundColor((int) background);
                        } else {
                            ViewCompat.setBackground(view, (Drawable) background);
                        }
                        break;
                    case "src":
                        // 这一行就实现了:通过app的资源ID--->找到app的资源Name--->皮肤包的资源ID
                        background = SkinResources.getInstance().getBackground(skinPair.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(skinPair.resId));
                        break;
                    case "drawableLeft":
                        left = SkinResources.getInstance().getDrawable(skinPair.resId);
                        break;
                    case "drawableTop":
                        top = SkinResources.getInstance().getDrawable(skinPair.resId);
                        break;
                    case "drawableRight":
                        right = SkinResources.getInstance().getDrawable(skinPair.resId);
                        break;
                    case "drawableBottom":
                        bottom = SkinResources.getInstance().getDrawable(skinPair.resId);
                        break;
                    default:
                        break;
                }
                if (null != left || null != right || null != top || null != bottom) {
                    ((TextView) view).setCompoundDrawablesWithIntrinsicBounds(left, top, right, bottom);
                }
            }
        }

        private void applySkinSupport() {
            if (view instanceof SkinViewSupport) {
                ((SkinViewSupport) view).applySkin();
            }
        }
    }

    static class SkinPair {
        /**
         * 属性名
         */
        String attributeName;
        /**
         * 对应的资源id
         */
        int resId;

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

总结

(1)通过自定义 LayoutInflater.Factory2来实现可控制的View创建过程,让我们能够对所有的创建的View,收集他们需要换肤的属性,以及属性对应的ID;

(2)定义观察者与被观察者,观察者就是每一个页面,被观察者就是换肤类。当换肤类触发了换肤之后,会通过状态分发让所有页面更新,如果还没有启动的Activiyt是否就不能更新了呢?当然不会,因为我们的可以直接调用 SkinManager.getInstance().loadSkin(skinPkg),来实现未启动的页面的更新。

(3)通过app的资源ID--->找到app的资源Name--->皮肤包的资源ID(为什么要这么做?因为我们换肤的时候给的资源的名肯定是一样的,但是这两个资源的ID在编译之后,在app和皮肤包里边一般是不同的,所以我们需要通过这种方式来获取到皮肤包的资源ID然后设置给app),这也是为什么我们在最初解析的时候需要使用下边的代码:

arduino 复制代码
static class SkinPair {
    /**
     * 属性名
     */
    String attributeName;
    /**
     * 对应的资源id
     */
    int resId;

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

测试源码:github.com/xingchaozha...

几个问题

1、自定义View的绘制流程:

1、View几个构造方法的区别?

less 复制代码
public class CustomView extends View  {
 /**
     * 一般在直接new一个view的时候使用
     * @param context
     */
    public CustomView(Context context) {
        super(context);
    }

    /**
     * 一般在layout文件中使用的时候回调用,关于它的属性(包括自定义属性)都会在attrs中传递进来。
     * @param context
     * @param attrs
     */
    public BigView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
       // <com.example.MyView
       //     android:layout_width="wrap_content"
       //     android:layout_height="wrap_content"
       //     custom:myAttribute="value" />

        //如果在这里这样写就会调用到三个参数的构造方法
        //this(context, attrs,0);

    }

    /**
     * @param context
     * @param attrs
     * @param defStyleAttr 默认的style,指的是当期application或者activity所用的theme中默认的style,
     *                     且只有明确调用的时候才会生效,
     *                     如 this(context, attrs, com.android.internal.R.attr.imageButtonStyle);
     *                     注意:即使在view中使用了style这个属性,也不会调用这个构造方法,所以这个构造方法
     *                     也不考虑。
     */
    public CustomView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        //this(context, attrs, com.android.internal.R.attr.imageButtonStyle);
    }

   /**
     * android5.0以后的api才有
     * 如果第三个参数为0或者没有定义defStyleAttr时,第四个参数才起作用,它是style的引用.
     * 不同于`defStyleAttr`,`defStyleAttr`是在当前主题中查找样式,
     * 而`defStyleRes`直接引用一个明确的样式资源。这个构造方法提供了更多的灵活性来处理视图的样式。
     * @param context
     * @param attrs
     * @param defStyleAttr
     * @param defStyleRes
     */
    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    public BigView(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
    }
}

3、 我们自定义View的时候,在使用的时候需要加上全路径,为什么系统内置的LinearLayout,Relativelayout这些不用呢?请看这里:

arduino 复制代码
protected View onCreateView(String name, AttributeSet attrs)
        throws ClassNotFoundException {
    return createView(name, "android.view.", attrs);
}

因为他们在创建的时候把前缀加上去了。

4、 换肤的过程中,比如Color中直接通过#FF00000,这种方式, 是无法替换的,因为这种只有ID,而没有name。无法映射到皮肤包中。

相关推荐
小刘不知道叫啥28 分钟前
从异步传染浅谈代数效应
前端·javascript·reactjs
圈圈的熊29 分钟前
EZUIKit.js萤石云vue项目使用
前端·javascript·vue.js
hakesashou31 分钟前
python 怎么调用js
前端·javascript·python
xgq42 分钟前
使用ResizeObserver监测元素尺寸变化
前端·javascript·面试
一路上__有你1 小时前
Electron + ssh2 + xterm 实现一个简易的ssh客户端
前端·electron
2401_857297911 小时前
招联金融内推-2025校招
java·前端·算法·金融·求职招聘
2401_857297911 小时前
2025秋招内推|招联金融
java·前端·算法·金融·求职招聘
pink大呲花1 小时前
用css实现改变图片滤镜
前端·css·html
我不吃饼干呀1 小时前
只写后台管理的前端要怎么提升自己
前端
无限大.2 小时前
0基础学前端 day8 -- HTML表单
前端·html·状态模式