+1

[Xamarin Android] Implement your own binding engine (based on XML attributes)

Đầu tiên, chúng ta cần hiểu về MVVM design pattern. Đối với Xamarin Form, việc data binding đã được implemented sẵn vì UI chúng ta được dựng lên từ file xaml. Với Xamarin android, việc binding trực tiếp trên file UI axml là bất khả thi nếu không apply các framework đã có binding engine được tạo riêng vd như MVVMCross. Nhưng ở đây mình sẽ dùng MVVMLight Framework vì sự gọn nhẹ và dễ custom của nó. Nhưng cũng với "light" thì đây quả thực có những giới hạn của nó so với ông lớn MVVMCross.

Sau 1 hồi loay hoay google, tìm được resouces hữu ích, mình quyết định viết 1 binding engine cho riêng MVVMLight dựa trên các thẻ Xml để apply data binding trên xamarin android, Binding engine sẽ có những chức năng tưng tự như việc binding trên Xaml:

  • OneWay/TwoWay binding
  • Hỗ trợ Commands
  • Hỗ trợ cùng Converters
  • Callback khi 1 event được raised
  • Data binding lên UI

Note: Hiện tại đây vẫn là bản protoype và vẫn nằm trong ý tưởng, để đưa vào thực tế cần phải custom để phù hợp với ngữ cảnh của dự án hiện tại 😄

Ok, Let's start

Đầu tiên, tạo 1 base class cho ViewModel

public class BindableObject : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;
 
    protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
    {
        var handler = PropertyChanged;
        if (handler != null)
        {
            handler(this, new PropertyChangedEventArgs(propertyName));
        }
    }
}

Tiếp đến là tạo base activity các view của project:

public abstract class BindingActivity<TViewModel> : Activity where TViewModel : BindableObject
{
    public TViewModel DataContext { get; set; }
 
    public int ViewLayoutResourceId { get; set; }
 
    protected BindingActivity(int viewLayoutResourceId)
    {
        this.ViewLayoutResourceId = viewLayoutResourceId;
 
        this.DataContext = ViewModelFactory.Create<TViewModel>();
    }
 
    protected override void OnCreate(Bundle savedInstanceState)
    {
        base.OnCreate(savedInstanceState);
 
        this.SetContentView(this.ViewLayoutResourceId);
 
        BindingEngine.Initialize(this);
    }
}

BindingActivity có property DataContext, property này tương tự như trong Xaml, property này dùng để khởi tạo dữ liệu, nó tương tự như là DataSource vậy, là nơi chúng ta lấy ra data cho những property đc binding. Hay nói cách khác, nó là ViewModel của view tương ứng. Để dùng, bạn chỉ cần pass nó vào view của bạn như sau:

[Activity(Label = "SampleBindingEngine", MainLauncher = true, Icon = "@drawable/icon")]
public class MainActivity : BindingActivity<MainViewModel>
{
    int _count = 1;
 
    public MainActivity()
        : base(Resource.Layout.Main)
    {
    }
}

Example về 1 viewmodel (DataContext) đã được implement những thứ cơ bản:

public class MainViewModel : BindableObject
{
    private bool _isBusy;
 
    public bool IsBusy
    {
        get { return _isBusy; }
        set
        {
            _isBusy = value;
 
            this.OnPropertyChanged();
        }
    }
 
    private string _sampleText;
    public string SampleText
    {
        get { return _sampleText; }
        set
        {
            _sampleText = value;
 
            this.OnPropertyChanged();
 
            if (this.ButtonClickCommand != null)
            {
                this.ButtonClickCommand.RaiseCanExecuteChanged();
            }
        }
    }
 
    public RelayCommand ButtonClickCommand { get; set; }
 
    public MainViewModel()
    {
        this.SampleText = "Hey, Xamarin World!";
 
        this.ButtonClickCommand = new RelayCommand(o =>
        {
            this.SampleText = "Text Changed from First Command!";
        },
        o => !string.IsNullOrWhiteSpace(this.SampleText));
    }
 
    private void ClickOnButton()
    {
        this.SampleText = "Text Changed from Method!";
    }
}

Giờ đến file BindingEngine.cs, Magic nằm ở đây đây

BindingEngine.cs

private const string ViewLayoutResourceIdPropertyName = "ViewLayoutResourceId";
public static void Initialize<TViewModel>(BindingActivity<TViewModel> bindingActivity) where TViewModel : BindableObject
{
    
// TODO
}

Rồi, giờ bắt đầu lấy những elements XML có trong file view axml:

List<XElement> xmlElements = null;
 
// Find the value of the ViewLayoutResourceId property
var viewLayoutResourceIdProperty = bindingActivity.GetType().GetProperty(ViewLayoutResourceIdPropertyName);
var viewLayoutResourceId = (int)viewLayoutResourceIdProperty.GetValue(bindingActivity);
 
if (viewLayoutResourceId > -1)
{
    
// Load the XML elements of the view
    xmlElements = GetViewAsXmlElements(bindingActivity, viewLayoutResourceId);
}
 
/// <summary>
/// Returns the current view (activity) as a list of XML element.
/// </summary>
/// <typeparam name="TViewModel">The type of the ViewModel associated to the activity.</typeparam>
/// <param name="bindingActivity">The current activity we want to get as a list of XML elements.</param>
/// <param name="viewLayoutResourceId">The id corresponding to the layout.</param>
/// <returns>A list of XML elements which represent the XML layout of the view.</returns>
private static List<XElement> GetViewAsXmlElements<TViewModel>(BindingActivity<TViewModel> bindingActivity, int viewLayoutResourceId) where TViewModel : BindableObject
{
    List<XElement> xmlElements;
 
    using (var viewAsXmlReader = bindingActivity.Resources.GetLayout(viewLayoutResourceId))
    {
        using (var sb = new StringBuilder())
        {
            while (viewAsXmlReader.Read())
            {
                sb.Append(viewAsXmlReader.ReadOuterXml());
            }
 
            var viewAsXDocument = XDocument.Parse(sb.ToString());
            xmlElements = viewAsXDocument.Descendants().ToList();
        }
    }
 
    return xmlElements;
}

Tiếp đến sẽ lấy những elements XML nào có keywork là Binding attribute ra, nếu get đc element đó thì sẽ convert nó thành View và đưa vào list để lát nữa đổ data lên thôi:

private static readonly XName BindingOperationXmlNamespace = XNamespace.Get("http://schemas.android.com/apk/res-auto") + "Binding";
 
// If there is at least one 'Binding' attribute set in the XML file, get the view as objects
if (xmlElements != null && xmlElements.Any(xe => xe.Attribute(BindingOperationXmlNamespace) != null))
{
    viewElements = GetViewAsObjects(bindingActivity);
}
 
/// <summary>
/// Returns the current view (activity) as a list of .NET objects.
/// </summary>
/// <typeparam name="TViewModel">The type of the ViewModel associated to the activity.</typeparam>
/// <param name="bindingActivity">The current activity we want to get as a list of XML elements.</param>
/// <returns>A list of .NET objects which composed the view.</returns>
private static List<View> GetViewAsObjects<TViewModel>(BindingActivity<TViewModel> bindingActivity) where TViewModel : BindableObject
{
    
// Get the objects on the view
    var rootView = bindingActivity.Window.DecorView.FindViewById(Resource.Id.Content);
 
    return GetAllChildrenInView(rootView, true);
}
 
/// <summary>
/// Recursive method which returns the list of children contains in a view.
/// </summary>
/// <param name="rootView">The root/start view from which the analysis is performed.</param>
/// <param name="isTopRootView">True is the current root element is, in fact, the top view.</param>
/// <returns>A list containing all the views with their childrens.</returns>
private static List<View> GetAllChildrenInView(View rootView, bool isTopRootView = false)
{
    if (!(rootView is ViewGroup))
    {
        return new List<View> { rootView };
    }
 
    var childrens = new List<View>();
 
    var viewGroup = (ViewGroup)rootView;
 
    for (int i = 0; i < viewGroup.ChildCount; i++)
    {
        var child = viewGroup.GetChildAt(i);
 
        var childList = new List<View>();
        if (isTopRootView)
        {
            childList.Add(child);
        }
 
        childList.AddRange(GetAllChildrenInView(child));
 
        childrens.AddRange(childList);
    }
 
    return childrens;
}

Giờ sẽ extract ra những operations đã implemented trong XML elements có keywork tìm được:

if (xmlElements != null && xmlElements.Any() && viewElements != null && viewElements.Any())
{
    
// Get all the binding operations inside the XML file.
    var bindingOperations = ExtractBindingOperationsFromLayoutFile(xmlElements, viewElements);
    if (bindingOperations != null && bindingOperations.Any())
    {
        
// Find the value of the DataContext property (which is, in fact, our ViewModel)
        var viewModel = bindingActivity.DataContext as BindableObject;
        if (viewModel != null)
        {
            
// TODO
        }
    }
}
 
/// <summary>
/// Extract the Binding operations (represent by the Binding="" attribute in the XML file).
/// </summary>
/// <param name="xmlElements">The list of XML elements from which we want to extract the Binding operations.</param>
/// <param name="viewElements">The list of .NET objects corresponding to the elements of the view.</param>
/// <returns>A list containing all the binding operations (matching between the Source property, the Target property, the Control bound to the .NET property and the Mode of the binding).</returns>
private static List<BindingOperation> ExtractBindingOperationsFromLayoutFile(List<XElement> xmlElements, List<View> viewElements)
{
    var bindingOperations = new List<BindingOperation>();
 
    for (int i = 0; i < xmlElements.Count; i++)
    {
        var currentXmlElement = xmlElements.ElementAt(i);
 
        if (currentXmlElement.Attributes(BindingOperationXmlNamespace).Any())
        {
            var xmlBindings = currentXmlElement.Attributes(BindingOperationXmlNamespace);
 
            foreach (var xmlBindingAttribute in xmlBindings)
            {
 
                var xmlBindingValue = xmlBindingAttribute.Value;
 
                if (!xmlBindingValue.StartsWith("{") || !xmlBindingValue.EndsWith("}"))
                {
                    throw new InvalidOperationException(string.Format("The following XML binding operation is not well formatted, it should start with '{{' and end with '}}:'{0}{1}", Environment.NewLine, xmlBindingValue));
                }
 
                var xmlBindingOperations = xmlBindingValue.Split(';');
 
                foreach (var bindingOperation in xmlBindingOperations)
                {
                    if (!bindingOperation.Contains(","))
                    {
                        throw new InvalidOperationException(string.Format("The following XML binding operation is not well formatted, it should contains at least one ',' between Source and Target:{0}{1}", Environment.NewLine, xmlBindingValue));
                    }
 
                    var bindingSourceValueRegex = new Regex(@"Source=(\w+)");
                    var bindingSourceValue = bindingSourceValueRegex.Match(bindingOperation).Groups[1].Value;
 
                    var bindingTargetValueRegex = new Regex(@"Target=(\w+)");
                    var bindingTargetValue = bindingTargetValueRegex.Match(bindingOperation).Groups[1].Value;
 
                    var bindingConverterValueRegex = new Regex(@"Converter=(\w+)");
                    var bindingConverterValue = bindingConverterValueRegex.Match(bindingOperation).Groups[1].Value;
 
                    
// Converter parameter support using more than just a word.
                    var bindingConverterParameterValueRegex = new Regex(@"ConverterParameter='(\w+\s(.\w+)+)");
                    var bindingConverterParameterValue = bindingConverterParameterValueRegex.Match(bindingOperation).Groups[1].Value;
 
                    var bindingModeValue = BindingMode.OneWay;
 
                    var bindingModeValueRegex = new Regex(@"Mode=(\w+)");
                    var bindingModeValueRegexMatch = bindingModeValueRegex.Match(bindingOperation);
 
                    if (bindingModeValueRegexMatch.Success)
                    {
                        if (!System.Enum.TryParse(bindingModeValueRegexMatch.Groups[1].Value, true, out bindingModeValue))
                        {
                            throw new InvalidOperationException(string.Format("The Mode property of the following XML binding operation is not well formatted, it should be 'OneWay' or 'TwoWay':{0}{1}", Environment.NewLine, xmlBindingValue));
                        }
                    }
 
                    bindingOperations.Add(new BindingOperation { Control = viewElements.ElementAt(i), Source = bindingSourceValue, Target = bindingTargetValue, Converter = bindingConverterValue, ConverterParameter = bindingConverterParameterValue, Mode = bindingModeValue });
                }
 
            }
        }
    }
 
    return bindingOperations;
}

Và phân tích từng operartion để biết được Mode, Source, Target nó là gì để tiến hành setup việc đổ data lên. Đây là ví dụ về binding Event to Command huyền thoại:

var sourceProperty = typeof(TViewModel).GetProperty(bindingOperation.Source);
 
var bindingEvent = bindingOperation.Control.GetType().GetEvent(bindingOperation.Target);
if (bindingEvent != null)
{
    
// The target is an event of the control
 
    if (sourceProperty != null)
    {
        
// We need to ensure that the bound property implements the interface ICommand so we can call the "Execute" method
        var command = sourceProperty.GetValue(viewModel) as ICommand;
        if (command == null)
        {
            throw new InvalidOperationException(string.Format("The source property {0}, bound to the event {1}, needs to implement the interface ICommand.", bindingOperation.Source, bindingEvent.Name));
        }
 
        
// Add an event handler to the specified event to execute the command when event is raised
        var executeMethodInfo = typeof(ICommand).GetMethod("Execute", new[] { typeof(object) });
 
        AddHandler(bindingOperation.Control, bindingOperation.Target, () =>
        {
            if (!_preventUpdateForSourceProperty)
            {
                executeMethodInfo.Invoke(command, new object[] { null });
            }
        });
 
        
// Add an event handler to manage the CanExecuteChanged event of the command (so we can disable/enable the control attached to the command)
        var currentControl = bindingOperation.Control;
 
        var enabledProperty = currentControl.GetType().GetProperty("Enabled");
        if (enabledProperty != null)
        {
            enabledProperty.SetValue(currentControl, command.CanExecute(null));
 
            AddHandler(command, "CanExecuteChanged", () => enabledProperty.SetValue(currentControl, command.CanExecute(null)));
        }
    }
    else
    {
        
// If the Source property of the ViewModel is not a 'real' property, check if it's a method
        var sourceMethod = typeof(TViewModel).GetMethod(bindingOperation.Source, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static);
        if (sourceMethod != null)
        {
            if (sourceMethod.GetParameters().Length > 0)
            {
                
// We only support calls to methods without parameters
                throw new InvalidOperationException(string.Format("Method {0} should not have any parameters to be called when event {1} is raised.", sourceMethod.Name, bindingEvent.Name));
            }
 
            
// If it's a method, add a event handler to the specified event to execute the method when event is raised
            AddHandler(bindingOperation.Control, bindingOperation.Target, () =>
            {
                if (!_preventUpdateForSourceProperty)
                {
                    sourceMethod.Invoke(viewModel, null);
                }
            });
        }
        else
        {
            throw new InvalidOperationException(string.Format("No property or event named {0} found to bint it to the event {1}.", bindingOperation.Source, bindingEvent.Name));
        }
    }
}

Đây là ví dụ của 1 file axml có apply binding:

<!-- Simple OneWay binding -->
    <EditText
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:text="@string/Hello"
        local:Binding="{Source=SampleText, Target=Text}" />
<!-- Simple TwoWay binding -->
    <EditText
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:text="@string/Hello"
        local:Binding="{Source=SampleText, Target=Text, Mode=TwoWay}" />
<!-- Binding an event to a command -->
    <Button
        android:id="@+id/MyButton"
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:text="Change EditText From Command"
        local:Binding="{Source=ButtonClickCommand, Target=Click}" />
    <EditText
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:text="@string/Hello"
        local:Binding="{Source=SampleText2, Target=Text, Mode=TwoWay;Source=TextCommand, Target=TextChanged}" />
<!-- Binding an event to a method -->
    <Button
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:text="Change EditText From Method"
        local:Binding="{Source=ClickOnButton, Target=Click}" />
<!-- Binding with a converter -->
    <EditText
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:text="@string/Hello"
        local:Binding="{Source=SampleBool, Target=Text, Mode=TwoWay, Converter=BooleanToStringConverter}" />
<!-- Binding with a converter & converter parameter -->
    <EditText
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:text="@string/Hello"
        local:Binding="{Source=SampleBool, Target=Text, Mode=TwoWay, Converter=BooleanToStringConverter, ConverterParameter='You can put any string here'}" />
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:local="http://schemas.android.com/apk/res-auto"
    android:layout_height="match_parent"
    android:layout_width="match_parent"
    android:orientation="vertical">
    <TextView
        android:id="@+id/tv_dummy"
        android:layout_height="wrap_content"
        android:layout_width="match_parent"
        android:textColor="@color/textview_lable"
        local:Binding="{Source=[Password], Target=Text}" />
    <Button
        android:layout_height="wrap_content"
        android:layout_width="match_parent"
        local:Binding="{Source=[TermsConditionsTitle], Target=Text}" />
</LinearLayout>

Trên đây là phần mô tả về cơ chế binding trên axml dựa vào các elements, attribute XML, trên thực tế đã được custom, edit kha khá và đưa vào dự án thật. Đây chỉ là bản prototype nên mình khuyên các bạn hãy tạo cho mình 1 engine riêng dựa vào nó, cũng khá lợi hại ạ 😄. Chúc bạn thành công!

Bài viết có tham khảo từ nguồn: http://blog.thomaslebrun.net/2015/03/xamarin-implement-your-own-binding-engine-based-on-xml-attributes-for-you-xamarin-android-applications/#.WIbE0rZ969t

Q.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí