Xamarin Android Part 3
Xamarin Android for Android Developers
In the previous post I got to the point where I had created a PodcastUtilities app for Android using Xamarin Android. This is the third part of that journey which describes the steps I took to get activities, viewmodels and observables working.
Create AndroidLogic class library
As I said in part 1, in “real” android instrumentation tests, the tests just reference the main app directly however the received wisdom is that having test runner apps reference you main app was not a great plan in Xamarin Android, also see this answer on Stackoverflow, so I created a common shared assembly as it doesnt really make much difference to the build. The structure looks like this
I created the PodcastUtilities.AndroidLogic
assembly as an Android Class Library like this
Be sure to make sure it also targets Android 12 like the main app.
Adding development essentials
Before I could get to implementing any real code there were some base services that I needed to get into the app, for example logging and dependency injection. To initialise and house these services I needed to create my own custom application class.
Creating a custom application
To create a custom application class in Xamarin Android use the Application
attribute and derive from Android.App.Application
1
2
3
4
5
6
7
8
9
10
11
12
13
using Android.App;
namespace PodcastUtilities
{
[Application]
public class AndroidApplication : Application, IAndroidApplication
{
// we must have a ctor or the app will not start
protected AndroidApplication(System.IntPtr javaReference, JniHandleOwnership transfer) : base(javaReference, transfer)
{
}
}
}
There is a gotcha, if you do not have a constructor your app will crash on launch with this error
android.runtime.JavaProxyThrowable: System.NotSupportedException: Unable to activate instance of type PodcastUtilities.AndroidApplication from native handle 0x7fdcabc194 (key_handle 0x8d41e11).
---> System.MissingMethodException: No constructor found for PodcastUtilities.AndroidApplication::.ctor(System.IntPtr, Android.Runtime.JniHandleOwnership)
---> Java.Interop.JavaLocationException: Exception of type 'Java.Interop.JavaLocationException' was thrown.
--- End of inner exception stack trace ---
Adding NLog
One of the advantages of using .NET and C# for development is the range of choice for each of the components of an app. There are any number of logging frameworks that can be used I used NLog for this app largely because I could find some good examples of how to configure it.
For PodcastUtilities all that was needed was
- Add NUget reference for NLog to the main app and the logic dll
- Add NLog config to the app
Assets
- Initialise the logging in our custom
Application
class
The NLog.config
needs to be placed in the app Assets
, make sure the build action is AndroidAsset
.
Its an XML file and mine looks like this.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<?xml version="1.0" encoding="utf-8" ?>
<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.nlog-project.org/schemas/NLog.xsd NLog.xsd"
autoReload="true"
throwExceptions="false"
internalLogLevel="Off"
internalLogFile="c:\temp\nlog-internal.log">
<targets>
<target xsi:type="Console"
name="consoleTarget"
layout="PodcastUtilities-Tag ${uppercase:${level}} ${message}" />
<target xsi:type="File"
name="externalFileTarget"
fileName="set-in-code.csv"
archiveFileName="set-in-code.csv"
archiveEvery="Day"
archiveNumbering="Rolling"
maxArchiveFiles="7"
concurrentWrites="false"
keepFileOpen="false">
<layout xsi:type="CsvLayout" delimiter="Tab" quoting="Nothing" withHeader="true">
<column name="time" layout="${longdate:universalTime=true}" />
<column name="threadid" layout="${threadid}" />
<column name="level" layout="${level:upperCase=true}" />
<column name="callsite" layout="${callsite:includeSourcePath=true}" />
<column name="message" layout="${message}" />
<column name="stacktrace" layout="${callsite:includeSourcePath=true}" />
<column name="exception" layout="${exception:format=ToString}" />
</layout>
</target>
</targets>
<rules>
<logger name="*" writeTo="externalFileTarget" />
<logger name="*" writeTo="consoleTarget" />
</rules>
</nlog>
In the config file the filenames are specified as set-in-code.csv
. These filenames are actually set and the logging initialised from the application like this
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
[Application]
public class AndroidApplication : Application, IAndroidApplication
{
public ILogger Logger { get; private set; }
public override void OnCreate()
{
SetupExceptionHandler();
base.OnCreate();
var dirs = Context.GetExternalFilesDirs(null);
if (dirs != null && dirs[0] != null)
{
// use our external folder - it depends on package name
LoggerFactory = new NLoggerLoggerFactory(dirs[0].AbsolutePath);
}
else
{
// hard code and hope for the best
LoggerFactory = new NLoggerLoggerFactory($"/sdcard/Android/data/{this.PackageName}/files/");
}
Logger = LoggerFactory.Logger;
Logger.Debug(() => $"AndroidApplication:Logging init");
}
private void SetupExceptionHandler()
{
AppDomain.CurrentDomain.UnhandledException += CurrentDomainOnUnhandledException;
TaskScheduler.UnobservedTaskException += TaskSchedulerOnUnobservedTaskException;
}
private void TaskSchedulerOnUnobservedTaskException(object sender, UnobservedTaskExceptionEventArgs unobservedTaskExceptionEventArgs)
{
Logger?.LogException(() => "TaskSchedulerOnUnobservedTaskException", unobservedTaskExceptionEventArgs.Exception);
}
private void CurrentDomainOnUnhandledException(object sender, UnhandledExceptionEventArgs unhandledExceptionEventArgs)
{
Logger?.LogException(() => "CurrentDomainOnUnhandledException", unhandledExceptionEventArgs.ExceptionObject as Exception);
}
}
The call to create the NLoggerLoggerFactory
is passed the full path to the application’s public file storage area so that the logs can be seen from an on-device file manager.
SetupExceptionHandler
ensures that any unexpected crashes are logged.
The logging utility class looks like this
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
public interface ILogger
{
public delegate string MessageGenerator();
void Debug(MessageGenerator message);
void Warning(MessageGenerator message);
void LogException(MessageGenerator message, Exception ex);
}
public class NLogLogger : ILogger
{
private Logger nlogLogger;
public NLogLogger(Logger logger)
{
nlogLogger = logger;
}
public void Debug(ILogger.MessageGenerator message)
{
if (nlogLogger.IsEnabled(LogLevel.Debug))
{
// only call the message delegate if we are logging
nlogLogger.Debug(message());
}
}
public void Warning(ILogger.MessageGenerator message)
{
if (nlogLogger.IsEnabled(LogLevel.Warn))
{
// only call the message delegate if we are logging
nlogLogger.Warn(message());
}
}
public void LogException(ILogger.MessageGenerator message, Exception ex)
{
nlogLogger.Error(ex, message() + $" => {ex.Message}");
}
}
public interface ILoggerFactory
{
ILogger Logger { get; }
}
public class NLoggerLoggerFactory : ILoggerFactory
{
/// <summary>
/// Use whatever is in the config file
/// </summary>
public NLoggerLoggerFactory()
{
}
/// <summary>
/// setup the file target with the supplied folder
/// </summary>
/// <param name="folder">folder for log files</param>
public NLoggerLoggerFactory(String folder)
{
var config = LogManager.Configuration;
var target = config.FindTargetByName("externalFileTarget");
var fileTarget = target as FileTarget;
fileTarget.FileName = Path.Combine(folder, "logs/${shortdate}.log.csv");
fileTarget.ArchiveFileName = Path.Combine(folder, "logs/archive.{#}.log.csv");
LogManager.ReconfigExistingLoggers();
}
public ILogger Logger
{
get
{
var logger = LogManager.GetCurrentClassLogger();
return new NLogLogger(logger);
}
}
}
The MessageGenerator
delegate mechanism is so that the code that creates the log messages will not be called if logging is not enabled.
The constructor that takes a folder path parameter uses the LogManager.Configuration
to set the filenames in the config to be correct, in the case of PodcastUtilities it is set to be /sdcard/Android/data/com.andrewandderek.podcastutilities.debug/logs/${shortdate}.log.csv
for a debug build.
Note:
- The path is different for release builds.
- The
${shortdate}
macro is evaluated by NLog - NLog will ensure the folder exists
- The factory will always create a logger, it relies on the IoC container to ensure that its a singleton.
Adding dependency injection
PodcastUtilities.Common
already makes use of dependency injection by using PodcastUtilities.IoC
. The dependency injection mechanism is accessed via an interface IIocContainer
because we need to be able to use different IoC implementations depending upon the runtime environment, .NET Framework or .NET Standard. In .NET Standard PodcastUtilities.Common
uses Microsoft.Extensions.DependencyInjection and it would be much simpler if the app and the core utilities used the same container.
This was done by adding some initialisation code and methods in the custom application class like this.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
using PodcastUtilities.Common;
using PodcastUtilities.Common.Platform;
using PodcastUtilities.Ioc;
[Application]
public class AndroidApplication : Application, IAndroidApplication
{
public IIocContainer IocContainer { get; private set; }
/// <summary>
/// setup the core PodcastUtilities.Common components
/// </summary>
/// <returns>an initialised container</returns>
private static IIocContainer InitializeIocContainer()
{
var container = IocRegistration.GetEmptyContainer();
IocRegistration.RegisterSystemServices(container);
IocRegistration.RegisterPortableDeviceServices(container);
IocRegistration.RegisterFileServices(container);
IocRegistration.RegisterFeedServices(container);
IocRegistration.RegisterPlaylistServices(container);
return container;
}
/// <summary>
/// add extra android things to the container so we can inject our viewmodels
/// </summary>
/// <param name="container">container to add to</param>
/// <returns>the updated container</returns>
private IIocContainer AddExtrasToIocContainer(IIocContainer container)
{
// the container itself, for factories
container.Register<IIocContainer>(container);
// android things
container.Register<Context>(ApplicationContext);
container.Register<Application>(this);
// helpers
container.Register<IAndroidApplication>(this);
container.Register<ILogger>(Logger);
container.Register<ICrashReporter, CrashlyticsReporter>(IocLifecycle.Singleton);
container.Register<IAnalyticsEngine, FirebaseAnalyticsEngine>(IocLifecycle.Singleton);
container.Register<IResourceProvider, AndroidResourceProvider>(IocLifecycle.Singleton);
container.Register<IFileSystemHelper, FileSystemHelper>(IocLifecycle.Singleton);
// view models
container.Register<ViewModelFactory, ViewModelFactory>(IocLifecycle.Singleton);
container.Register<MainViewModel, MainViewModel>();
container.Register<SettingsViewModel, SettingsViewModel>();
container.Register<OpenSourceLicensesViewModel, OpenSourceLicensesViewModel>();
container.Register<DownloadViewModel, DownloadViewModel>();
container.Register<MessagesViewModel, MessagesViewModel>();
var factory = container.Resolve<ViewModelFactory>();
factory.AddMap(typeof(MainViewModel));
factory.AddMap(typeof(SettingsViewModel));
factory.AddMap(typeof(OpenSourceLicensesViewModel));
factory.AddMap(typeof(DownloadViewModel));
factory.AddMap(typeof(MessagesViewModel));
return container;
}
public override void OnCreate()
{
base.OnCreate();
// initialise the IoC container
IocContainer = InitializeIocContainer();
AddExtrasToIocContainer(IocContainer);
}
}
Now when the logic objects are resolved in the android app then dependencies from PodcastUtilities.Common
or from PodcastUtilities.AndroidLogic
can all be supplied with one call to the container.Resolve<>()
method.
ViewModels
Since the advent of Jetpack Google have been recommending that developers use viewmodels and I was keen to try and use them from Xamarin Android. This is the method that I used.
- Create a viewmodel by deriving from
AndroidX.Lifecycle.AndroidViewModel
, we want to be able to test the viewmodel and it is not referenced directly from the manifest so I put it in the AndroiLogic assembly. - Create an activity by deriving from
AndroidX.AppCompat.App.AppCompatActivity
, the activity is referenced from the manifest so I put it in the main app - Add the viewmodel to the IoC container and also to the
ViewModelFactory
map.
The ViewModel
The skeleton viewmodel looks like this
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class ExampleViewModel : AndroidViewModel, ILifecycleObserver
{
public class ObservableGroup
{
public EventHandler<string> Title;
}
public ObservableGroup Observables = new ObservableGroup();
private ILogger Logger;
private IResourceProvider ResourceProvider;
public ExampleViewModel(
ILogger logger,
IResourceProvider resProvider
) : base(app)
{
Logger = logger;
Logger.Debug(() => $"ExampleViewModel:ctor");
ResourceProvider = resProvider;
}
public void Initialise()
{
Logger.Debug(() => $"ExampleViewModel:Initialise");
Observables.Title?.Invoke(this, ResourceProvider.GetString(Resource.String.example_activity_title));
}
}
The construction parameters will be supplied from the IoC container.
The Activity
The activity looks like this
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// Title is set dynamically
[Activity(ParentActivity = typeof(MainActivity))]
public class ExampleActivity : AppCompatActivity
{
private DownloadViewModel ViewModel;
private AndroidApplication AndroidApplication;
protected override void OnCreate(Bundle savedInstanceState)
{
AndroidApplication = Application as AndroidApplication;
AndroidApplication.Logger.Debug(() => $"ExampleActivity:OnCreate");
base.OnCreate(savedInstanceState);
// Set our view from the layout resource
SetContentView(Resource.Layout.activity_download);
var factory = AndroidApplication.IocContainer.Resolve<ViewModelFactory>();
ViewModel = new ViewModelProvider(this, factory).Get(Java.Lang.Class.FromType(typeof(ExampleViewModel))) as ExampleViewModel;
Lifecycle.AddObserver(ViewModel);
SetupViewModelObservers();
ViewModel.Initialise();
AndroidApplication.Logger.Debug(() => $"ExampleActivity:OnCreate - end");
}
protected override void OnDestroy()
{
base.OnDestroy();
KillViewModelObservers();
}
The method Java.Lang.Class.FromType(typeof(ExampleViewModel))
gets the Java class information for the class that is the Java wrapper generated by the build tools for the supplied .NET class.
The ViewModelFactory
I guess the clever bit of this code is in the ViewModelFactory
we hand this off to the AndroidX.Lifecycle.ViewModelProvider
and as long as we do the right thing when asked, Android will not know we are any different from any other Java/Kotlin consumer.
The right thing in this instance looks like this
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public class ViewModelFactory : Java.Lang.Object, ViewModelProvider.IFactory
{
public struct ClassMap
{
public Java.Lang.Class javaClassType;
public Type netType;
}
private IIocContainer IocContainer;
private ILogger Logger;
private Dictionary<string, ClassMap> ModelMap = new Dictionary<string, ClassMap>(10);
public ViewModelFactory(
IIocContainer container,
ILogger logger
)
{
IocContainer = container;
Logger = logger;
}
public void AddMap(ClassMap item)
{
Logger.Debug(() => $"ViewModelFactory:AddMap {item.javaClassType.CanonicalName}");
ModelMap.Add(item.javaClassType.CanonicalName, item);
}
public void AddMap(Type type)
{
AddMap(new ClassMap() { javaClassType = Java.Lang.Class.FromType(type), netType = type });
}
public Java.Lang.Object Create(Java.Lang.Class classType)
{
Logger.Debug(() => $"ViewModelFactory:Create {classType.CanonicalName}");
// instead of a generic call like this
// var obj = IocContainer.Resolve<DownloadViewModel>();
// we do this - because we do not know the type (DownloadViewModel in this example) at compile time
MethodInfo method = typeof(IIocContainer).GetMethod(nameof(IIocContainer.Resolve));
MethodInfo generic = method.MakeGenericMethod(ModelMap[classType.CanonicalName].netType);
var obj = generic.Invoke(IocContainer, null);
Logger.Debug(() => $"ViewModelFactory:Create null == {obj == null}");
return (Java.Lang.Object)obj;
}
}
Android will call Create
and will pass in the Java
class it want to be created. Internally in the ViewModelFactory
we have a map of java classes to .NET classes so we can get the IocContainer to create the .NET type (we need to do this via reflection as we dont know the type at compile time) and then we just cast it back to being a Java object and hand it back.
As we are using the IocContainer to resolve the type any dependencies or lifestyle restrictions will be satisfied by the IocContainer. As the IocContainer is an injected dependency of the factory we place the container in the container in the AndroidApplication
.
The Application
The only other bit that is needed is to ensure the factory and container have the ExampleViewModel
added to them when the application is started like this
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/// <summary>
/// add extra android things to the container so we can inject our viewmodels
/// </summary>
/// <param name="container">container to add to</param>
/// <returns>the updated container</returns>
private IIocContainer AddExtrasToIocContainer(IIocContainer container)
{
// the container itself, for factories
container.Register<IIocContainer>(container);
// android things
container.Register<Context>(ApplicationContext);
container.Register<Application>(this);
// helpers
container.Register<IAndroidApplication>(this);
container.Register<ILogger>(Logger);
container.Register<ICrashReporter, CrashlyticsReporter>(IocLifecycle.Singleton);
container.Register<IAnalyticsEngine, FirebaseAnalyticsEngine>(IocLifecycle.Singleton);
container.Register<IResourceProvider, AndroidResourceProvider>(IocLifecycle.Singleton);
container.Register<IFileSystemHelper, FileSystemHelper>(IocLifecycle.Singleton);
// view models
container.Register<ViewModelFactory, ViewModelFactory>(IocLifecycle.Singleton);
container.Register<MainViewModel, MainViewModel>();
container.Register<SettingsViewModel, SettingsViewModel>();
container.Register<OpenSourceLicensesViewModel, OpenSourceLicensesViewModel>();
container.Register<DownloadViewModel, DownloadViewModel>();
container.Register<MessagesViewModel, MessagesViewModel>();
container.Register<ExampleViewModel, ExampleViewModel>();
var factory = container.Resolve<ViewModelFactory>();
factory.AddMap(typeof(MainViewModel));
factory.AddMap(typeof(SettingsViewModel));
factory.AddMap(typeof(OpenSourceLicensesViewModel));
factory.AddMap(typeof(DownloadViewModel));
factory.AddMap(typeof(MessagesViewModel));
factory.AddMap(typeof(ExampleViewModel));
return container;
}
If you forget to add the viewmodel to the factory then you will get an error like this
The given key 'crc644c7cb612129105ac.PurgeViewModel' was not present in the dictionary.
Collections.Generic.KeyNotFoundException: The given key 'crc644c7cb612129105ac.PurgeViewModel' was not present in the dictionary.
at System.Collections.Generic.Dictionary`2[TKey,TValue].get_Item (TKey key) [0x0001e] in <a8b4702b60024176b74a9d7a0c8fe330>:0
at PodcastUtilities.AndroidLogic.ViewModel.ViewModelFactory.Create (Java.Lang.Class classType) [0x00055] in <9aa43d2f76e444f8a2df9c9e1a95a089>:0
at AndroidX.Lifecycle.ViewModelProvider+IFactoryInvoker.n_Create_Ljava_lang_Class_ (System.IntPtr jnienv, System.IntPtr native__this, System.IntPtr native_modelClass) [0x0000f] in <3bc29452d8cf4fe3980f8edceb3ad872>:0
at (wrapper dynamic-method) Android.Runtime.DynamicMethodNameCounter.4(intptr,intptr,intptr)
--- End of stack trace from previous location where exception was thrown ---
at Java.Interop.JniEnvironment+InstanceMethods.CallObjectMethod (Java.Interop.JniObjectReference instance, Java.Interop.JniMethodInfo method, Java.Interop.JniArgumentValue* args) [0x0006e] in <2e109281f9514c53b44688fd4549adb2>:0
at Java.Interop.JniPeerMembers+JniInstanceMethods.InvokeVirtualObjectMethod (System.String encodedMember, Java.Interop.IJavaPeerable self, Java.Interop.JniArgumentValue* parameters) [0x0002a] in <2e109281f9514c53b44688fd4549adb2>:0
at AndroidX.Lifecycle.ViewModelProvider.Get (Java.Lang.Class modelClass) [0x00031] in <3bc29452d8cf4fe3980f8edceb3ad872>:0
at PodcastUtilities.UI.Purge.PurgeActivity.OnCreate (Android.OS.Bundle savedInstanceState) [0x00101] in <ad7fdac40ea443a59474142a1a615fe1>:0
at Android.App.Activity.n_OnCreate_Landroid_os_Bundle_ (System.IntPtr jnienv, System.IntPtr native__this, System.IntPtr native_savedInstanceState) [0x0000f] in <96214b62e5264246be943a5d64d16954>:0
at (wrapper dynamic-method) Android.Runtime.DynamicMethodNameCounter.5(intptr,intptr,intptr)
Lifecycle
If you want to have your viewmodel lifecycle aware then you can do that by deriving the viewmodel from ILifecycleObserver
as well as AndroidViewModel
and add methods like this
1
2
3
4
5
6
7
8
9
10
11
12
13
[Lifecycle.Event.OnResume]
[Java.Interop.Export]
public void OnResume()
{
Logger.Debug(() => $"ExampleViewModel:OnResume");
}
[Lifecycle.Event.OnPause]
[Java.Interop.Export]
public void OnPause()
{
Logger.Debug(() => $"ExampleViewModel:OnPause");
}
These methods will be called directly from Android without going via the activity.
However do be aware when you do this you must add a reference to Mono.Android.Export.dll
or you will get an error like this
error XA4210: Please add a reference to Mono.Android.Export.dll when using ExportAttribute or ExportFieldAttribute.
Observable data
In order that viewmodels work properly they must not directly refer to the activity or view. Instead we use some form of observable data mechanism.
LiveData
In “real” android applications the most obvious choice is to use LiveData. To get that working I did this
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
public class ExampleViewModel : AndroidViewModel, ILifecycleObserver
{
public class LiveDataObservableGroup
{
public LiveDataObservableGroup(ILiveDataFactory livedateFactory)
{
Title = livedateFactory.CreateMutableLiveData();
}
public MutableLiveData Title { get; private set; }
}
public LiveDataObservableGroup LiveDataObservables;
private ILogger Logger;
public ExampleViewModel(
ILogger logger,
ILiveDataFactory livedateFactory
) : base(app)
{
Logger = logger;
Logger.Debug(() => $"ExampleViewModel:ctor");
LiveDataObservables = new LiveDataObservableGroup(livedateFactory);
}
public void Initialise()
{
Logger.Debug(() => $"ExampleViewModel:Initialise");
LiveDataObservables.Title.PostValue("Observed LiveData Title");
}
}
public interface ILiveDataFactory
{
MutableLiveData CreateMutableLiveData();
}
public class LiveDataFactory : ILiveDataFactory
{
public MutableLiveData CreateMutableLiveData()
{
return new MutableLiveData();
}
}
[Activity(ParentActivity = typeof(MainActivity))]
public class ExampleActivity : AppCompatActivity
{
private ExampleViewModel ViewModel;
private AndroidApplication AndroidApplication;
private class ExampleTitleObserver : Java.Lang.Object, IObserver
{
ExampleActivity Activity;
public ExampleTitleObserver(ExampleActivity downloadActivity)
{
Activity = downloadActivity;
}
public void OnChanged(Java.Lang.Object o)
{
string value = (string)o;
Activity.Title = value;
}
}
protected override void OnCreate(Bundle savedInstanceState)
{
AndroidApplication = Application as AndroidApplication;
AndroidApplication.Logger.Debug(() => $"ExampleActivity:OnCreate");
base.OnCreate(savedInstanceState);
// Set our view from the layout resource
SetContentView(Resource.Layout.activity_example);
var factory = AndroidApplication.IocContainer.Resolve<ViewModelFactory>();
ViewModel = new ViewModelProvider(this, factory).Get(Java.Lang.Class.FromType(typeof(ExampleViewModel))) as ExampleViewModel;
Lifecycle.AddObserver(ViewModel);
SetupLiveDataViewModelObservers();
ViewModel.Initialise();
AndroidApplication.Logger.Debug(() => $"ExampleActivity:OnCreate - end");
}
protected override void OnStop()
{
base.OnStop();
// the LiveData observers are automatically removed at this point because of the androidx lifecycle
}
private void SetupLiveDataViewModelObservers()
{
ViewModel.LiveDataObservables.Title.Observe(this, new ExampleTitleObserver(this));
}
}
The ILiveDataFactory
is present to eliminate newing up the concrete MutableLiveData
so that the viewmodel can be tested.
Having the nested ExampleTitleObserver
class to receive the event is a little clunky and could get messy with lots of events. Also every object passed to OnChanged
needs to be wrapped in a Java object which means that complex data types need to derive from Java.Lang.Object
. This is a consequence of using the android observable implementation LiveData
C# EventHandlers
C# events have been baked into the language almost from the beginning. There is considerable advantage when the observer and observed code are both .NET.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
public class ExampleViewModel : AndroidViewModel, ILifecycleObserver
{
public class ObservableGroup
{
public EventHandler<string> Title;
}
public ObservableGroup Observables = new ObservableGroup();
private ILogger Logger;
public ExampleViewModel(
ILogger logger
) : base(app)
{
Logger = logger;
Logger.Debug(() => $"ExampleViewModel:ctor");
}
public void Initialise()
{
Logger.Debug(() => $"ExampleViewModel:Initialise");
Observables.Title?.Invoke(this, "Observed Title");
}
}
[Activity(ParentActivity = typeof(MainActivity))]
public class ExampleActivity : AppCompatActivity
{
private ExampleViewModel ViewModel;
private AndroidApplication AndroidApplication;
protected override void OnCreate(Bundle savedInstanceState)
{
AndroidApplication = Application as AndroidApplication;
AndroidApplication.Logger.Debug(() => $"ExampleActivity:OnCreate");
base.OnCreate(savedInstanceState);
// Set our view from the layout resource
SetContentView(Resource.Layout.activity_example);
var factory = AndroidApplication.IocContainer.Resolve<ViewModelFactory>();
ViewModel = new ViewModelProvider(this, factory).Get(Java.Lang.Class.FromType(typeof(ExampleViewModel))) as ExampleViewModel;
Lifecycle.AddObserver(ViewModel);
SetupViewModelObservers();
ViewModel.Initialise();
AndroidApplication.Logger.Debug(() => $"ExampleActivity:OnCreate - end");
}
protected override void OnStop()
{
base.OnStop();
KillObservers();
}
private void SetupViewModelObservers()
{
ViewModel.Observables.Title += SetTitle;
}
private void KillObservers()
{
ViewModel.Observables.Title -= SetTitle;
}
private void SetTitle(object sender, string title)
{
Title = title;
}
}
The Invoke
mechanism is far less clunky than using LiveData the only advantage that LiveData has is that when we use EventHandler
we must remove the handler in the activity OnStop()
method.
The other advantage is that it is easy to use complex .NET types like Tuple in this manner
In the viewmodel
1
2
3
public EventHandler<Tuple<ISyncItem, Status, string>> UpdateItemStatus;
Observables.UpdateItemStatus?.Invoke(this, Tuple.Create(item, Status.Error, e.Message));
In the activity
1
2
3
4
5
6
7
8
9
private void UpdateItemStatus(object sender, Tuple<ISyncItem, Status, string> updateItem)
{
RunOnUiThread(() =>
{
(ISyncItem item, Status status, string message) = updateItem;
var position = Adapter.SetItemStatus(item.Id, status, message);
Adapter.NotifyItemChanged(position);
});
}
Its worth noting that if we raise events from background threads then we need to wrap any UI code in RunOnUiThread
. I think this would apply to both EventHandler
and LiveData
.
Having setup the app structure the next step will be to write tests for the viewmodels.