如何在 Android 的 viewModel 中获取参考资料(R.string)(MVVM 和数据绑定)

我目前使用的机器人 databindingMVVM architecture。在 ViewModel 中获取字符串资源的最佳方法是什么。

我没有使用新的 AndroidViewModel组件,eventbusRxJava

我正在研究接口的方法,其中 Activity 将负责提供资源。但是最近我在 这个的答案中发现了一个类似的问题,其中一个使用应用程序上下文的类提供了所有的资源。

哪种方法更好? 还是我可以试试别的方法?

62976 次浏览

You can access the context by implementing AndroidViewModel instead of ViewModel.

class MainViewModel(application: Application) : AndroidViewModel(application) {
fun getSomeString(): String? {
return getApplication<Application>().resources.getString(R.string.some_string)
}
}

You can also use the Resource Id and ObservableInt to make this work.

ViewModel:

val contentString = ObservableInt()


contentString.set(R.string.YOUR_STRING)

And then your view can get the text like this:

android:text="@{viewModel.contentString}"

This way you can keep the context out of your ViewModel

You can use the Resource Id to make this work.

ViewModel

 val messageLiveData= MutableLiveData<Any>()


messageLiveData.value = "your text ..."

or

messageLiveData.value = R.string.text

And then use it in fragment or activity like this:

messageLiveData.observe(this, Observer {
when (it) {
is Int -> {
Toast.makeText(context, getString(it), Toast.LENGTH_LONG).show()
}
is String -> {
Toast.makeText(context, it, Toast.LENGTH_LONG).show()
}
}
}

Just create a ResourceProvider class that fetch resources using Application context. In your ViewModelFactory instantiate the resource provider using App context. You're Viewmodel is Context free and can be easily testable by mocking the ResourceProvider.

Application

public class App extends Application {


private static Application sApplication;


@Override
public void onCreate() {
super.onCreate();
sApplication = this;


}


public static Application getApplication() {
return sApplication;
}

ResourcesProvider

public class ResourcesProvider {
private Context mContext;


public ResourcesProvider(Context context){
mContext = context;
}


public String getString(){
return mContext.getString(R.string.some_string);
}

ViewModel

public class MyViewModel extends ViewModel {


private ResourcesProvider mResourcesProvider;


public MyViewModel(ResourcesProvider resourcesProvider){
mResourcesProvider = resourcesProvider;
}


public String doSomething (){
return mResourcesProvider.getString();
}

ViewModelFactory

public class ViewModelFactory implements ViewModelProvider.Factory {


private static ViewModelFactory sFactory;


private ViewModelFactory() {
}


public static ViewModelFactory getInstance() {
if (sFactory == null) {
synchronized (ViewModelFactory.class) {
if (sFactory == null) {
sFactory = new ViewModelFactory();
}
}
}
return sFactory;
}


@SuppressWarnings("unchecked")
@NonNull
@Override
public <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
if (modelClass.isAssignableFrom(MainActivityViewModel.class)) {
return (T) new MainActivityViewModel(
new ResourcesProvider(App.getApplication())
);
}
throw new IllegalArgumentException("Unknown ViewModel class");
}

}

Ideally Data Binding should be used with which this problem can easily be solved by resolving the string inside the xml file. But implementing data binding in an existing project can be too much.

For a case like this I created the following class. It covers all cases of strings with or without arguments and it does NOT require for the viewModel to extend AndroidViewModel and this way also covers the event of Locale change.

class ViewModelString private constructor(private val string: String?,
@StringRes private val stringResId: Int = 0,
private val args: ArrayList<Any>?){


//simple string constructor
constructor(string: String): this(string, 0, null)


//convenience constructor for most common cases with one string or int var arg
constructor(@StringRes stringResId: Int, stringVar: String): this(null, stringResId, arrayListOf(stringVar))
constructor(@StringRes stringResId: Int, intVar: Int): this(null, stringResId, arrayListOf(intVar))


//constructor for multiple var args
constructor(@StringRes stringResId: Int, args: ArrayList<Any>): this(null, stringResId, args)


fun resolve(context: Context): String {
return when {
string != null -> string
args != null -> return context.getString(stringResId, *args.toArray())
else -> context.getString(stringResId)
}
}
}

USAGE

for example we have this resource string with two arguments

<string name="resource_with_args">value 1: %d and value 2: %s </string>

In ViewModel class:

myViewModelString.value = ViewModelString(R.string.resource_with_args, arrayListOf(val1, val2))

In Fragment class (or anywhere with available context)

textView.text = viewModel.myViewModelString.value?.resolve(context)

Keep in mind that the * on *args.toArray() is not a typing mistake so do not remove it. It is syntax that denotes the array as Object...objects which is used by Android internaly instead of Objects[] objects which would cause a crash.

an updated version of Bozbi's answer using Hilt

ViewModel.kt

@HiltViewModel
class MyViewModel @Inject constructor(
private val resourcesProvider: ResourcesProvider
) : ViewModel() {
...
fun foo() {
val helloWorld: String = resourcesProvider.getString(R.string.hello_world)
}
...
}

ResourcesProvider.kt

@Singleton
class ResourcesProvider @Inject constructor(
@ApplicationContext private val context: Context
) {
fun getString(@StringRes stringResId: Int): String {
return context.getString(stringResId)
}
}

Not at all.

Resource string manipulation belongs the View layer, not ViewModel layer.

ViewModel layer should be free from dependencies to both Context and resources. Define a data type (a class or enum) that ViewModel will emit. DataBinding has access to both Context and resources and can resolve it there. Either via @BindingAdapter (if you want the clean look) or a plain static method (if you want flexibility and verbosity) that takes the enum and Context and returns String : android:text="@{MyStaticConverter.someEnumToString(viewModel.someEnum, context)}". (context is synthetic param in every binding expression)

But in most cases, String.format is enough to combine resource string format with data provided by ViewModel.

It may seem like "too much code in XML", but XML and bindings are the View layer. The only places for view logic, if you discard god-objects: Activities and Fragments.

//edit - more detailed example (kotlin):

object MyStaticConverter {
@JvmStatic
fun someEnumToString(type: MyEnum?, context: Context): String? {
return when (type) {
null -> null
MyEnum.EENY -> context.getString(R.string.some_label_eeny)
MyEnum.MEENY -> context.getString(R.string.some_label_meeny)
MyEnum.MINY -> context.getString(R.string.some_label_miny)
MyEnum.MOE -> context.getString(R.string.some_label_moe)
}
}
}

usage in XML:

<data>
<import type="com.example.MyStaticConverter" />
</data>
...
<TextView
android:text="@{MyStaticConverter.someEnumToString(viewModel.someEnum, context)}".

For more complicated cases (like mixing resource labels with texts from API) instead of enum use sealed class that will carry the dynamic String from ViewModel to the converter that will do the combining.

For simplest cases, like above, there is no need to invoke Context explicitly at all. The built-in adapter already interprets binding int to text as string resource id. The tiny inconvenience is that when invoked with null the converter still must return a valid ID, so you need to define some kind of placeholder like <string name="empty" translatable="false"/>.

@StringRes
fun someEnumToString(type: MyEnum?): Int {
return when (type) {
MyEnum.EENY -> R.string.some_label_eeny
MyEnum.MEENY -> R.string.some_label_meeny
MyEnum.MINY -> R.string.some_label_miny
MyEnum.MOE -> R.string.some_label_moe
null -> R.string.empty
}
}

Quick-and-dirty hack would be to emit a @StringRes Int directly, but that makes ViewModel dependent on resources.

"Converters" (a collection of unrelated, static and stateless functions) is a pattern that I use a lot. It allows to keep all the Android's View-related types away from ViewModel and reuse of small, repetitive parts across entire app (like converting bool or various states to VISIBILITY or formatting numbers, dates, distances, percentages, etc). That removes the need of many overlapping @BindingAdapters and IMHO increases readability of the XML-code.

I don't use data bindig but I guess you can add an adapter for my solution.

I keep resource ids in the view model

class ExampleViewModel: ViewModel(){
val text = MutableLiveData<NativeText>(NativeText.Resource(R.String.example_hi))
}

and get text on a view layer.

viewModel.text.observe(this) { text
textView.text = text.toCharSequence(this)
}

You can read more about native text in the article

For old code which you don't want to refactor you can create an ad-hoc class as such

private typealias ResCompat = AppCompatResources


@Singleton
class ResourcesDelegate @Inject constructor(
@ApplicationContext private val context: Context,
) {


private val i18nContext: Context
get() = LocaleSetter.createContextAndSetDefaultLocale(context)


fun string(@StringRes resId: Int): String = i18nContext.getString(resId)


fun drawable(@DrawableRes resId: Int): Drawable? = ResCompat.getDrawable(i18nContext, resId)


}

and then use it inside your AndroidViewModel.

@HiltViewModel
class MyViewModel @Inject constructor(
private val resourcesDelegate: ResourcesDelegate
) : AndroidViewModel() {
    

fun foo() {
val helloWorld: String = resourcesDelegate.string(R.string.hello_world)
}

If you are using Dagger Hilt then @ApplicationContext context: Context in your viewModel constructor will work. Hilt can automatically inject application context with this annotation. If you are using dagger then you should provide context through module class and then inject in viewModel constructor. Finally using that context you can access the string resources. like context.getString(R.strings.name)

Still don't find here this simple solution:

android:text="@{viewModel.location == null ? @string/unknown : viewModel.location}"