Почему Xamarin.Forms так медленно при отображении нескольких ярлыков (особенно на Android)?

Мы пытаемся выпустить некоторые продуктивные приложения с помощью Xamarin.Forms, но одной из наших основных проблем является общая медлительность между нажатием кнопки и отображением содержимого. После нескольких экспериментов мы обнаружили, что даже простая ContentPage с 40 ярлыками занимает более 100 мс для отображения:

public static class App
{
    public static DateTime StartTime;

    public static Page GetMainPage()
    {    
        return new NavigationPage(new StartPage());
    }
}

public class StartPage : ContentPage
{
    public StartPage()
    {
        Content = new Button {
            Text = "Start",
            Command = new Command(o => {
                App.StartTime = DateTime.Now;
                Navigation.PushAsync(new StopPage());
            }),
        };
    }
}

public class StopPage : ContentPage
{
    public StopPage()
    {
        Content = new StackLayout();
        for (var i = 0; i < 40; i++)
            (Content as StackLayout).Children.Add(new Label{ Text = "Label " + i });
    }

    protected override void OnAppearing()
    {
        ((Content as StackLayout).Children[0] as Label).Text = "Stop after " + (DateTime.Now - App.StartTime).TotalMilliseconds + " ms";

        base.OnAppearing();
    }
}

Особенно на Android все хуже, чем больше ярлыков, которые вы пытаетесь отобразить. Первое нажатие кнопки (что важно для пользователя) даже занимает ~ 300 мс. Нам нужно показать что-то на экране менее чем за 30 мс, чтобы создать хороший пользовательский интерфейс.

Зачем так долго с Xamarin.Forms отображать некоторые простые метки? И как обойти эту проблему для создания shippable App?

<сильные > Эксперименты

Код можно разветкить на GitHub в https://github.com/perpetual-mobile/XFormsPerformance

Я также написал небольшой пример, демонстрирующий, что аналогичный код, использующий собственные API от Xamarin.Android, значительно быстрее и не становится медленнее при добавлении большего количества контента: https://github.com/perpetual-mobile/XFormsPerformance/tree/android-native-api

Ответы

Ответ 1

Команда поддержки Xamarin написала мне:

Команда знает о проблеме, и они работают над оптимизацией Инициализация кода пользовательского интерфейса. Вы можете увидеть некоторые улучшения в предстоящем релизов.

Обновление: после семи месяцев бездействия Xamarin изменил отчет об ошибке на "ПОДТВЕРЖДЕН".

Полезно знать. Поэтому мы должны быть терпеливыми. К счастью, Шон Маккей в Xamarin Forums предложил переопределить весь макет кода для повышения производительности: https://forums.xamarin.com/discussion/comment/87393#Comment_87393

Но его предложение также означает, что мы должны снова написать полный код метки. Вот версия FixedLabel, которая не выполняет дорогостоящих циклов компоновки и имеет некоторые функции, такие как связующие свойства для текста и цвета. Использование этого вместо Label повышает производительность на 80% и более в зависимости от количества меток и места их возникновения.

public class FixedLabel : View
{
    public static readonly BindableProperty TextProperty = BindableProperty.Create<FixedLabel,string>(p => p.Text, "");

    public static readonly BindableProperty TextColorProperty = BindableProperty.Create<FixedLabel,Color>(p => p.TextColor, Style.TextColor);

    public readonly double FixedWidth;

    public readonly double FixedHeight;

    public Font Font;

    public LineBreakMode LineBreakMode = LineBreakMode.WordWrap;

    public TextAlignment XAlign;

    public TextAlignment YAlign;

    public FixedLabel(string text, double width, double height)
    {
        SetValue(TextProperty, text);
        FixedWidth = width;
        FixedHeight = height;
    }

    public Color TextColor {
        get {
            return (Color)GetValue(TextColorProperty);
        }
        set {
            if (TextColor != value)
                return;
            SetValue(TextColorProperty, value);
            OnPropertyChanged("TextColor");
        }
    }

    public string Text {
        get {
            return (string)GetValue(TextProperty);
        }
        set {
            if (Text != value)
                return;
            SetValue(TextProperty, value);
            OnPropertyChanged("Text");
        }
    }

    protected override SizeRequest OnSizeRequest(double widthConstraint, double heightConstraint)
    {
        return new SizeRequest(new Size(FixedWidth, FixedHeight));
    }
}

Android Renderer выглядит так:

public class FixedLabelRenderer : ViewRenderer
{
    public TextView TextView;

    protected override void OnElementChanged(ElementChangedEventArgs<Xamarin.Forms.View> e)
    {
        base.OnElementChanged(e);

        var label = Element as FixedLabel;
        TextView = new TextView(Context);
        TextView.Text = label.Text;
        TextView.TextSize = (float)label.Font.FontSize;
        TextView.Gravity = ConvertXAlignment(label.XAlign) | ConvertYAlignment(label.YAlign);
        TextView.SetSingleLine(label.LineBreakMode != LineBreakMode.WordWrap);
        if (label.LineBreakMode == LineBreakMode.TailTruncation)
            TextView.Ellipsize = Android.Text.TextUtils.TruncateAt.End;

        SetNativeControl(TextView);
    }

    protected override void OnElementPropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        if (e.PropertyName == "Text")
            TextView.Text = (Element as FixedLabel).Text;

        base.OnElementPropertyChanged(sender, e);
    }

    static GravityFlags ConvertXAlignment(Xamarin.Forms.TextAlignment xAlign)
    {
        switch (xAlign) {
            case Xamarin.Forms.TextAlignment.Center:
                return GravityFlags.CenterHorizontal;
            case Xamarin.Forms.TextAlignment.End:
                return GravityFlags.End;
            default:
                return GravityFlags.Start;
        }
    }

    static GravityFlags ConvertYAlignment(Xamarin.Forms.TextAlignment yAlign)
    {
        switch (yAlign) {
            case Xamarin.Forms.TextAlignment.Center:
                return GravityFlags.CenterVertical;
            case Xamarin.Forms.TextAlignment.End:
                return GravityFlags.Bottom;
            default:
                return GravityFlags.Top;
        }
    }
}

И здесь iOS Render:

public class FixedLabelRenderer : ViewRenderer<FixedLabel, UILabel>
{
    protected override void OnElementChanged(ElementChangedEventArgs<FixedLabel> e)
    {
        base.OnElementChanged(e);

        SetNativeControl(new UILabel(RectangleF.Empty) {
            BackgroundColor = Element.BackgroundColor.ToUIColor(),
            AttributedText = ((FormattedString)Element.Text).ToAttributed(Element.Font, Element.TextColor),
            LineBreakMode = ConvertLineBreakMode(Element.LineBreakMode),
            TextAlignment = ConvertAlignment(Element.XAlign),
            Lines = 0,
        });

        BackgroundColor = Element.BackgroundColor.ToUIColor();
    }

    protected override void OnElementPropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        if (e.PropertyName == "Text")
            Control.AttributedText = ((FormattedString)Element.Text).ToAttributed(Element.Font, Element.TextColor);

        base.OnElementPropertyChanged(sender, e);
    }

    // copied from iOS LabelRenderer
    public override void LayoutSubviews()
    {
        base.LayoutSubviews();
        if (Control == null)
            return;
        Control.SizeToFit();
        var num = Math.Min(Bounds.Height, Control.Bounds.Height);
        var y = 0f;
        switch (Element.YAlign) {
            case TextAlignment.Start:
                y = 0;
                break;
            case TextAlignment.Center:
                y = (float)(Element.FixedHeight / 2 - (double)(num / 2));
                break;
            case TextAlignment.End:
                y = (float)(Element.FixedHeight - (double)num);
                break;
        }
        Control.Frame = new RectangleF(0, y, (float)Element.FixedWidth, num);
    }

    static UILineBreakMode ConvertLineBreakMode(LineBreakMode lineBreakMode)
    {
        switch (lineBreakMode) {
            case LineBreakMode.TailTruncation:
                return UILineBreakMode.TailTruncation;
            case LineBreakMode.WordWrap:
                return UILineBreakMode.WordWrap;
            default:
                return UILineBreakMode.Clip;
        }
    }

    static UITextAlignment ConvertAlignment(TextAlignment xAlign)
    {
        switch (xAlign) {
            case TextAlignment.Start:
                return UITextAlignment.Left;
            case TextAlignment.End:
                return UITextAlignment.Right;
            default:
                return UITextAlignment.Center;
        }
    }
}

Ответ 2

Здесь вы измеряете сумму:

  • время, необходимое для создания страницы
  • для компоновки
  • для анимации на экране

В nexus5 я наблюдаю времена в размере 300 мс для первого вызова и 120 мс для последующих.

Это связано с тем, что OnAppearing() будет вызываться только при полном анимировании представления.

Вы можете легко измерить время анимации, заменив приложение:

public class StopPage : ContentPage
{
    public StopPage()
    {
    }

    protected override void OnAppearing()
    {
        System.Diagnostics.Debug.WriteLine ((DateTime.Now - App.StartTime).TotalMilliseconds + " ms");        
        base.OnAppearing();
    }
}

и я наблюдаю такие моменты, как:

134.045 ms
2.796 ms
3.554 ms

Это дает некоторые идеи:  - нет анимации на PushAsync на Android (там на iPhone, беря 500 мс)  - в первый раз, когда вы нажимаете страницу, вы платите налог в 120 миллионов долларов из-за нового распределения.  - XF делает хорошую работу по повторному использованию средств визуализации страниц.

Нам интересно время для показа 40 ярлыков, ничего больше. Снова измените код:

public class StopPage : ContentPage
{
    public StopPage()
    {
    }

    protected override void OnAppearing()
    {
        App.StartTime = DateTime.Now;

        Content = new StackLayout();
        for (var i = 0; i < 40; i++)
            (Content as StackLayout).Children.Add(new Label{ Text = "Label " + i });

        System.Diagnostics.Debug.WriteLine ((DateTime.Now - App.StartTime).TotalMilliseconds + " ms");

        base.OnAppearing();
    }
}

раз (по 3 вызовам):

264.015 ms
186.772 ms
189.965 ms
188.696 ms

Это все еще слишком много, но по мере того, как ContentView устанавливается первым, это составляет 40 циклов компоновки, так как каждый новый ярлык перерисовывает экран. Пусть изменение:

public class StopPage : ContentPage
{
    public StopPage()
    {
    }

    protected override void OnAppearing()
    {
        App.StartTime = DateTime.Now;

        var layout = new StackLayout();
        for (var i = 0; i < 40; i++)
            layout.Children.Add(new Label{ Text = "Label " + i });

        Content = layout;
        System.Diagnostics.Debug.WriteLine ((DateTime.Now - App.StartTime).TotalMilliseconds + " ms");

        base.OnAppearing();
    }
}

И вот мои измерения:

178.685 ms
110.221 ms
117.832 ms
117.072 ms

Это становится очень разумным, особенно. учитывая, что вы рисуете (создаете и измеряете) 40 ярлыков, когда ваш экран может отображать только 20.

В действительности действительно есть место для улучшения, но ситуация не так плоха, как кажется. Правило 30 мс для мобильных устройств говорит, что все, что занимает больше 30 мс, должно быть асинхронным, а не блокировать пользовательский интерфейс. Здесь для переключения страницы требуется более 30 мс, но с точки зрения пользователя я не воспринимаю это как медленное.

Ответ 3

В настройках свойств Text и TextColor класса FixedLabel в коде говорится:

Если новое значение "отличается" от текущего, то ничего не делайте!

Это должно быть с противоположным условием, так что если новое значение того же, что и текущее, тогда делать нечего.