在运行时覆盖资源

64

问题

我想要在运行时覆盖我的应用程序资源,如R.color.brand_color或R.drawable.ic_action_start。我的应用程序连接到将提供品牌色彩和图像的CMS系统。一旦应用程序下载了CMS数据,它需要能够重新设置自身外观。

我知道你马上就要说了 - 在运行时覆盖资源是不可能的。

但事实上,它有点可能。特别是我发现了这篇来自2012年的学士论文,它解释了基本概念 - Android中的Activity类扩展了ContextWrapper,其中包含了attachBaseContext方法。您可以覆盖attachBaseContext以使用自己的自定义类包装Context,该自定义类覆盖了getColor和getDrawable等方法。您自己的getColor实现可以根据需要查找颜色。 Calligraphy库使用类似的方法注入一个自定义LayoutInflator,可以处理加载自定义字体。

代码

我创建了一个简单的Activity,使用此方法覆盖颜色的加载。

public class MainActivity extends Activity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    }

    @Override
    protected void attachBaseContext(Context newBase) {
        super.attachBaseContext(new CmsThemeContextWrapper(newBase));
    }

    private class CmsThemeContextWrapper extends ContextWrapper{

        private Resources resources;

        public CmsThemeContextWrapper(Context base) {
            super(base);
            resources = new Resources(base.getAssets(), base.getResources().getDisplayMetrics(), base.getResources().getConfiguration()){
                @Override
                public void getValue(int id, TypedValue outValue, boolean resolveRefs) throws NotFoundException {
                    Log.i("ThemeTest", "Getting value for resource " + getResourceName(id));
                    super.getValue(id, outValue, resolveRefs);
                    if(id == R.color.theme_colour){
                        outValue.data = Color.GREEN;
                    }
                }

                @Override
                public int getColor(int id) throws NotFoundException {
                    Log.i("ThemeTest", "Getting colour for resource " + getResourceName(id));
                    if(id == R.color.theme_colour){
                        return Color.GREEN;
                    }
                    else{
                        return super.getColor(id);
                    }
                }
            };
        }

        @Override
        public Resources getResources() {
            return resources;
        }
    }
}

问题是,它不起作用!日志显示调用了加载资源的函数,例如layout/activity_main和mipmap/ic_launcher,但color/theme_colour从未被加载。看起来上下文被用于创建窗口和操作栏,但不是活动内容视图。

我的问题是 - 如果不是活动的上下文,布局填充器从哪里加载资源?我还想知道 - 是否有可行的方法在运行时覆盖颜色和可绘制的加载?

关于替代方法的说明

我知道可以从CMS数据的其他方式主题化应用程序 - 例如,我们可以创建一个方法getCMSColour(String key),然后在我们的onCreate()中编写以下代码:

myTextView.setTextColour(getCMSColour("heading_text_colour"))

对于可绘制对象、字符串等也可以采用类似的方法。然而,这将导致大量的样板代码,而且所有这些代码都需要维护。修改 UI 时很容易忘记设置特定视图的颜色。

封装 Context 以返回我们自己的自定义值更加“简洁”,而且不太容易出现问题。在探索替代方法之前,我想了解为什么它不起作用。


1
你的解决方案有效:在活动中,如果调用getResources().getColor(R.color.theme_colour),结果如预期的那样是Color.GREEN。似乎布局填充器使用另一种检索颜色的方法,我不知道它们其中哪一个。我尝试包装应用程序上下文,但结果仍然相同... - Médéric
虽然这不是你问题的答案(实际上,如果这种方法可行的话我会非常感兴趣),但作为另一种替代方案,你可以自己覆盖小部件(TextView、ImageView等),并使用自己实现的“资源提供者”来替代原本的资源。这样,你可以减少重复的代码量,并且更容易维护主题。至少,这是我个人在其他方法都失败时会采取的方法,而不是在每个活动/片段中覆盖主题和资源。 - kha
@kha - 感谢您的评论。我在问题中概述的方法并没有使用任何反射,只是通过子类化来覆盖公共方法。您能否分享一些关于布局加载器从哪里加载资源的信息 - 它不是活动上下文吗?为什么我的重写的getValue和getColor方法从未被调用以解析我在布局xml中引用的资源? - Luke Sleeman
如果Calligraphy的ContextWrapper在膨胀其布局时有效,而您的ContextWrapper则无效,则根据定义,Calligraphy的ContextWrapper和您的ContextWrapper之间存在某些差异。现在,我还没有使用过Calligraphy,它的ContextWrapper可能与您的类似失败。 - CommonsWare
2
啊,我明白了。对于之前的混淆,我很抱歉。在理想的世界里,你的方法是可行的。在理想的世界里,我会有头发。可惜这不是一个理想的世界。 - CommonsWare
显示剩余8条评论
3个回答

10
尽管“动态覆盖资源”可能看起来是解决问题的直接方法,但我认为更清洁的方法是使用官方数据绑定实现https://developer.android.com/tools/data-binding/guide.html,因为它不涉及对 Android 的“黑客攻击”。
您可以使用POJO传递您的品牌设置。不要使用静态样式如@color/button_color,而应编写@{brandingConfig.buttonColor}并将您的视图与所需的值进行绑定。借助适当的Activity层次结构,这不应添加太多样板代码。
这还使您能够更改布局中的更复杂元素,例如:根据品牌设置在其他布局中包含不同的布局,使您的UI高度可配置而不需要太多努力。

1
我刚刚尝试了数据绑定库,它似乎很适合我们所需的用途。唯一缺少的是数据绑定品牌配置的美观设计预览 - 例如,在android studio中执行类似于android:textColor="@{brandingConfig.buttonColor}"的操作不会在视觉布局预览中产生任何有趣的结果。这可以通过添加带有“tools:”前缀的属性来解决,以设置预览的字符串、drawable等。 - Luke Sleeman
1
经过更多的思考,我将这个标记为答案。你没有回答问题“如果不是活动上下文,布局充气机从哪里加载资源?”。但是,你已经成功地回答了问题“是否有一种可行的方法在运行时覆盖颜色和可绘制的加载?”并且系统非常接近我想要实现的目标。此外,向你致敬,因为你意识到问题是数据绑定的问题。本质上,我试图做的就是用最少的样板代码从Java中获取许多值,并将它们放入XML布局中。 - Luke Sleeman

8

和Luke Sleeman一样,我遇到了相同的问题,因此查看了在解析XML布局文件时LayoutInflater创建视图的方式。我着重检查了为什么布局内的TextView文本属性分配的字符串资源不被我的自定义ContextWrapper返回的Resources对象覆盖。同时,当通过TextView.setText()TextView.setHint()以编程方式设置文本或提示时,字符串被预期地覆盖。

以下是在TextView构造函数中(sdk v 23.0.1)如何接收文本作为CharSequence

// android.widget.TextView.java, line 973
text = a.getText(attr);

其中a是之前获取的TypedArray

 // android.widget.TextView.java, line 721
 a = theme.obtainStyledAttributes(attrs, com.android.internal.R.styleable.TextView, defStyleAttr, defStyleRes);

Theme.obtainStyledAttributes()方法调用AssetManager上的本地方法:

// android.content.res.Resources.java line 1593
public TypedArray obtainStyledAttributes(AttributeSet set,
            @StyleableRes int[] attrs, @AttrRes int defStyleAttr, @StyleRes int defStyleRes) {
...
        AssetManager.applyStyle(mTheme, defStyleAttr, defStyleRes,
                parser != null ? parser.mParseState : 0, attrs, array.mData, array.mIndices);

...

以下是AssetManager.applyStyle()方法的声明:

// android.content.res.AssetManager.java, line 746
/*package*/ native static final boolean applyStyle(long theme,
        int defStyleAttr, int defStyleRes, long xmlParser,
        int[] inAttrs, int[] outValues, int[] outIndices);

总之,即使LayoutInflater使用了正确的扩展上下文,在填充XML布局和创建视图时,方法Resources.getText()(在自定义ContextWrapper返回的资源上)也从未被调用以获取文本属性的字符串,因为TextView的构造函数直接使用AssetManager来加载属性的资源。对于其他视图和属性可能也是如此。

1
啊 - 感谢您找出了难题的最后一块拼图 - 布局加载器从哪里加载资源!很遗憾看到包装上下文以实现动态主题的方法永远不会奏效。 - Luke Sleeman
这似乎是本地的applyStyle实现:https://github.com/aosp-mirror/platform_frameworks_base/blob/c5d02da0f6553a00da6b0d833b67d3bbe87341e0/libs/androidfw/AttributeResolution.cpp#L205 - tmm1

5
在搜索了相当长一段时间后,我终于找到了一个极好的解决方案。
protected void redefineStringResourceId(final String resourceName, final int newId) {
        try {
            final Field field = R.string.class.getDeclaredField(resourceName);
            field.setAccessible(true);
            field.set(null, newId);
        } catch (Exception e) {
            Log.e(getClass().getName(), "Couldn't redefine resource id", e);
        }
    }

一个样例测试,
private Object initialStringValue() {
                // TODO Auto-generated method stub
                 return getString(R.string.initial_value);
            }

在主活动内部,
before.setText(getString(R.string.before, initialStringValue()));

            final String resourceName = getResources().getResourceEntryName(R.string.initial_value);
            redefineStringResourceId(resourceName, R.string.evil_value);

            after.setText(getString(R.string.after, initialStringValue()));

这个解决方案最初由Roman Zhilich发布。

ResourceHackActivity


1
确实可以使用反射使R对象上的字段指向新资源 - 问题是新资源也需要在XML中静态定义!我们对来自CMS的颜色、字符串、可绘制等动态获取的内容感兴趣。因此,使用您的解决方案,您可以轻松地将R.string.initial_value = R.string.evil_value,但无法从其他地方注入动态字符串。 - Luke Sleeman

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接