Русский
Русский
English
Статистика
Реклама

Declarative

5 советов для прокачки своих навыков в Angular

14.09.2020 16:14:47 | Автор: admin

Этим летом мы с Ромой запустили серию твитов с полезными советами и приемами по Angular. Сообщество тепло встретило эту инициативу, и я решил написать обобщающую статью.Вот мои 5 рекомендаций, которыми хочется поделиться с разработчиками. Эти советы будут подкреплены конкретными примерами из моего твиттера. Они помогут вам поднять свои навыки или как минимум дадут пару практических приемов.

1. Разберитесь в работе механизма проверки изменений

В интернете множество отличных углубленных статей про проверку изменений в Angular. Например, вот эта. Так что давайте просто освежим основы и перейдем к советам.

Основы

В Angular два режима проверки изменений: Default и OnPush. Первый запускает проверку на каждый tick внутри приложения. Этим управляет Zone.js, которая патчит все асинхронные операции вроде подписок на события и промисов. Второй режим помечает view для проверки, только если в нем случилось слушаемое событие или изменились входные данные.

Default vs OnPush

По правде говоря, вряд ли есть причины использовать режим Default. Просто пишите код так, как задумано принципами фреймворка, и вы не попадете в неприятности с OnPush. Это означает никогда не мутировать данные. Если ваши входные объекты или массивы немутабельные, OnPush подцепит изменения и обновит вью.

Подписки на события через @HostListener Angular заметит и в OnPush. Но что делать, если вы используете RxJS? Всегда можно заинжектить ChangeDetectorRef и вызвать markForCheck(), когда потребуется. Декларативным решением тут будет async пайп, если стрим в итоге доходит до шаблона. Он сам запустит проверку и возьмет на себя отписку.

Вам наверняка попадался такой паттерн:

<div *ngIf="stream$ | async as result"></div>

Но что делать, если вам важны также falsy-результаты? Можно выкинуть всю логику на условие из ngIf и сделать свою простую структурную директиву. Она будет использоваться, только чтобы объявить контекст для вложенного вью:

Код
@Directive({  selector: "[ngLet]"})export class LetDirective<T> {  @Input()  ngLet: T;  constructor(    @Inject(ViewContainerRef) container: ViewContainerRef,    @Inject(TemplateRef) templateRef: TemplateRef<LetContext<T>>  ) {    container.createEmbeddedView(templateRef, new LetContext<T>(this));  }}

NgZone

Если у вас нет возможности полностью перейти на OnPush, можно провести оптимизации. Заинжектите NgZone и выполняйте нагруженные операции в .runOutsideAngular(). Таким образом не будет возникать лишних тиков в механизме проверки изменений. Даже компоненты в режиме Default не будут реагировать на эти операции. Это уместно делать для частых событий, таких как mousemove или scroll. Это можно сделать декларативно в RxJS-стримах с помощью двух операторов: один для выхода из зоны, другой для возврата в нее, чтобы запустить проверку изменений:

Код
class ZonefreeOperator<T> implements Operator<T, T> {  constructor(private readonly zone: NgZone) {}  call(observer: Observer<T>, source: Observable<T>): TeardownLogic {    return this.zone.runOutsideAngular(      () => source.subscribe(observer)    );  }}export function zonefull<T>(zone: NgZone): MonoTypeOperatorFunction<T> {  return map(value => zone.run(() => value));}export function zonefree<T>(zone: NgZone): MonoTypeOperatorFunction<T> {  return source => source.lift(new ZonefreeOperator(zone));}

Еще один вариант, работающий с @HostListener, создать свой EventManagerPlugin. Мы выпустили open-source-библиотеку под названием ng-event-plugins. Она позволяет отсеивать лишние проверки изменений. Подробнее об этом читайте в этой статье.

2. Хорошенько разберитесь в RxJS

RxJS очень мощный инструмент. Мы все его используем в той или иной мере, но именно хорошее владение им очень сильно поможет вам. От простых стримов, которые позволят быстро перезагрузить компонент

до сложных комбинаций операторов. Например, чтобы понять, настоящий у вас скролл или инерционный:

Только посмотрите, как просто создать шапку, исчезающую при прокрутке сайта вниз. Совсем немного CSS и базовый RxJS:

Код
@Directive({  selector: "[sticky]",  providers: [DestroyService]})export class StickyDirective {  constructor(    @Inject(DestroyService) destroy$: Observable<void>,    @Inject(WINDOW) windowRef: Window,    renderer: Renderer2,    { nativeElement }: ElementRef<HTMLElement>  ) {    fromEvent(windowRef, "scroll")      .pipe(        map(() => windowRef.scrollY),        pairwise(),        map(([prev, next]) => next < THRESHOLD || prev > next),        distinctUntilChanged(),        startWith(true),        takeUntil(destroy$)      )      .subscribe(stuck => {        renderer.setAttribute(          nativeElement,           "data-stuck",           String(stuck)        );      });  }}

Сложно переоценить знание RxJS для Angular-разработчика в долгосрочной перспективе. Простой на первый взгляд RxJS требует некоего смещения парадигмы, чтобы начать мыслить потоками. Но, когда вы это сделаете, ваш код станет аккуратнее и легче в поддержке.

Тут особо нечего советовать, кроме как больше практиковаться . Если видите ситуацию, которую можно решить через RxJS, попытайтесь сделать это. Избегайте сайд-эффектов и вложенных подписок. Держите свои стримы в порядке и следите за утечками памяти (подробнее об этом дальше).

3. Выжимайте максимум из TypeScript

Мы все пишем Angular-приложения на TypeScript. Но, чтобы получить максимальную пользу, нужно задействовать его целиком. Я редко вижу проекты с включенным strict: true. Вам определенно следует сделать это. Оно спасет вас от множества cannot read property of null и undefined is not a function.

Дженерики

В TypeScript существуют дженерики для случаев, когда тип, с которым мы работаем, неизвестен. Комбинация дженериков, перегрузок и сужения типов позволит вам сделать крутой API. Почти никогда не придется делать тайпкаст. Посмотрите на этот пример типизированного RxJS-метода fromEvent:

Код
// Тип события с конкретным currentTargetexport type EventWith<  E extends Event,  T extends FromEventTarget<E>> = E & {  readonly currentTarget: T;};// Типизированный вариант fromEventexport function typedFromEvent<  E extends keyof GlobalEventHandlersEventMap,  T extends FromEventTarget<EventWith<GlobalEventHandlersEventMap[E], T>>>(  target: T,  event: E,  options: AddEventListenerOptions = {},): Observable<EventWith<GlobalEventHandlersEventMap[E], T>> {  return fromEvent(target, event, options);}

С ним вы будете уверены, что событие имеет конкретный тип, а currentTarget это элемент, на котором вы его слушаете.

API, построенные на дженериках, хороши тем, что они не завязаны на формат данных. А значит, их легко расширять и другим разработчикам не придется подстраиваться под определенный интерфейс.

Есть немало статей о продвинутой типизации и всевозможных трюках с TypeScript. Я очень советую расширять свои знания в этой области, это поможет вам писать надежный код. Мой последний совет: не используйте any. Если вы не можете применить дженерик, более безопасным выбором будет unknown.

Декораторы

Не забывайте про другие инструменты TypeScript, такие как декораторы. При умелом использовании они могут значительно улучшить ваш код. К примеру, бывают ситуации, когда тип переменной корректный, но значение не подходит: отрицательное или дробное число не подходит для обозначения количества, хотя тип и там, и там number. TypeScript такое не отловит, но мы можем защитить компоненты в runtime с помощью декоратора:

Код
export function assert<T, K extends keyof T>(  assertion: (input: T[K]) => boolean,  messsage: string): PropertyDecorator {  return (target, key) => {    Object.defineProperty(target, key, {      set(this: T, initialValue: T[K]) {        let currentValue = initialValue;        Object.defineProperty(this, key, {          get(): T[K] {            return currentValue;          },          set(this: T, value: T[K]) {            console.assert(assertion(value), messsage);            currentValue = value;          }        });      }    });  };}

Вы знали, что декорированный абстрактный класс не нуждается в пробросе аргументов в super()? Angular сам сделает это за вас, если не использовать конструктор в дочернем классе:

Еще один хороший пример для декораторов переиспользуемая логика обработки. Посмотрите кусок кода из нашей библиотеки для Web Audio API в Angular, где мы превращаем декларативный байндинг в императивные нативные команды с помощью строго типизированного декоратора. Подробнее про саму библиотеку можно почитать тут.

4. Dependency Injection. Используйте его почаще

DI одна из причин, почему Angular такой мощный фреймворк. Возможно, это даже главная причина. Но слишком часто его не используют по полной.

Могу порекомендовать нашу статью, посвященную DI, чтобы освоиться с этим инструментом.

RxJS

Что касается практических советов, выше я уже говорил быть внимательными к утечкам памяти в RxJS. Главным образом это означает, что, если вы подписались на стрим руками, вам же и придется от него отписаться. Идиоматическим решение в Angular будет инкапсуляция подобной логики в сервис:

Код
@Injectable()export class DestroyService extends Subject<void> implements OnDestroy {    ngOnDestroy() {        this.next();        this.complete();    }}

Вы также можете создавать общие стримы и добавлять их в DI. Нет необходимости создавать отдельные потоки на базе requestAnimationFrame в вашем приложении. Создайте токен и переиспользуйте его. Вы даже можете заложить в него операторы для выхода из зоны, описанные выше:

Токены

DI хороший инструмент для повышения абстрактности вашего кода. Если вы не зависите от глобальных объектов вроде window или navigator ваше приложение готово к использование в Angular Universal в серверном окружении. Такой код просто тестируется, так как все его зависимости легко подменить на заглушки. Глобальные объекты без труда превращаются в токены. Внутри фабрики при объявлении токена нам доступен глобальный инжектор. Нам потребуется всего пара строк для создания WINDOW токена на базе встроенного DOCUMENT:

Код
export const WINDOW = new InjectionToken<Window>(  'An abstraction over global window object',  {    factory: () => {      const {defaultView} = inject(DOCUMENT);      if (!defaultView) {        throw new Error('Window is not available');      }      return defaultView;    },  },);

Чтобы не тратить на это время, используйте нашу open-source-библиотеку, где мы уже реализовали многие такие токены. А для Angular Universal есть ее библиотека-сестра с качественными заглушками. Не стесняйтесь, пишите нам, если вам нужен еще какой-то токен.

Токены и фабрики дают массу возможностей. В сочетании с иерархической структурой DI они позволяют сделать приложение очень модульным. Можете почитать больше про использование провайдеров в этой статье.

5. Бросайте императора в бездну, как Вейдер

Angular со своим синтаксисом шаблонов и декораторами подталкивает нас к декларативному подходу. Я уже упоминал его. Не будем сейчас вдаваться в подробности о его преимуществах по сравнению с императивным кодом. Я просто дам вам четкий совет: пишите декларативный код. Когда вы к нему привыкнете, то заметите, насколько все стало лучше.

Геттеры

Что это означает писать декларативный код? В первую очередь постарайтесь использовать ngOnChanges как можно реже. Это плохо типизированный сайд-эффект, который нужен, по сути, только когда надо выполнить какое-то действие при изменении нескольких входных значений.

Если есть необходимость отреагировать на изменение одного инпута сеттер будет более прямолинейным решением. Если же вместо действия вы собирались обновить какое-то внутреннее состояние, убедитесь что его нельзя заменить вычисляемым геттером.

Производительность и геттеры тема для отдельной статьи, до которой я надеюсь вскоре добраться. А пока возьмите за правило не создавать новых объектов или массивов внутри геттеров. Это существенно более затратная операция, чем арифметика или сложение строк. Если вам требуется пересоздать объект, воспользуйтесь техникой мемоизации к примеру, чистым пайпом.

Это хороший пример для сеттера, который я опустил для краткости, но про который мне напомнил внимательный читатель.

Template reference variables

Вместо ручного запрашивания элементов из шаблона в Angular предусмотрен декоратор @ViewChild. Однако зачастую он не нужен, если можно завернуть передачу элемента в шаблоне:

<input #input><button (click)="onClick(input)">Focus</button>

Здесь мы передаем template reference variable непосредственно в метод, где она нужна. Так код нашего компонента остается чище. Думайте об этом как о подобии замыкания в шаблоне.

Но что, если мы хотим получить DOM-элемент, который является компонентом? Мы могли бы написать @ViewChild(MyComponent, {read: ElementRef}), но мы обойдемся без поля класса, если создадим директиву с exportAs:

Код
@Directive({    selector: '[element]',    exportAs: 'elementRef',})export class ElementDirective<T extends Element> extends ElementRef<T> {    constructor(@Inject(ElementRef) {nativeElement}: ElementRef<T>) {        super(nativeElement);    }}

Динамический контент

Люди часто используют ComponentFactoryResolver для императивного создания динамических компонентов. Зачем, если есть директива ngComponentOutlet? Потому что так мы получим доступ к экземпляру компонента и сможем передать в него данные. Хороший способ решения подобной задачи опять же, Dependency Injection. ngComponentOutlet позволяет передать Injector, который мы создадим и подложим в него данные через токен.

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

Мы уже давно используем этот подход, не зависящий от типа переданного контента. Мы вынесли его в крошечную open-source-библиотеку под названием ng-polymorpheus. Она не делает ничего, кроме передачи контента в соответствующий встроенный инструмент, ngTemplateOutlet, ngContentOutlet или же простую интерполяцию с вызовом функции. Когда привыкаешь к такому, обратной дороги уже нет! Подробнее читайте в этой статье.

На этом все. Надеюсь, мои советы будут вам полезны. Приятного кодинга!

Подробнее..

Декларативный подход в Angular

12.11.2020 18:15:59 | Автор: admin

Когда я впервые услышал про compliant-механизмы, был весьма впечатлен. Хоть они и окружают нас в повседневности в виде застежек рюкзака, кнопок мыши или колпачков от шампуней, мы редко задумываемся о концепции таких устройств.

Если говорить кратко, в compliant-механизме для обеспечения его технических характеристик используют деформацию. В то время как в традиционной технике (rigid body) гибкость зачастую является негативным качеством материала, сompliant-механизмы используют ее для передачи силы и движения в нужном направлении, вместо соединений из нескольких подвижных деталей.

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

Я нахожу в этом отличную аналогию на декларативный подход в программировании в противовес императивному, в котором, подобно передачи движения через шарниры и оси, команды управляют изменениями состояния. И я хотел бы поделиться определенной философией написания Angular-кода, сложившейся у меня за время работы над библиотекой переиспользуемых компонентов для пользовательского интерфейса.

Compliant-компоненты

Angular изначально предполагает определенную долю декларативности: дата-байндинг, работа с событиями, активное использование observable-модели. Несложные компоненты буквально описаны утверждениями, что чем является и как связано с окружением. Они не выглядят набором инструкций, изменяющих состояние в ту или иную сторону. Однако с усложнением задачи компонент может легко утратить это описательное качество и превратиться в череду вызовов, подписок и хранимых данных, отвечающих за его поведение, синхронизировать которые становится всё сложнее.

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

Линейный график

Это довольно незамысловатый компонент. Мы передаем ему массив пар чисел и хотим, чтобы SVG построил по ним путь. Мы постараемся организовать наш код так, чтобы в нем не было императивных манипуляций с состоянием. Можно повесить компонент прямо на нативный SVG, чтобы избежать вложенности:

@Component({  selector: "svg[lineChart]",  templateUrl: "./line-chart.template.html",  styleUrls: ["./line-chart.style.less"],  changeDetection: ChangeDetectionStrategy.OnPush,  host: {        preserveAspectRatio: "none"  }})export class LineChartComponent {}

Кроме данных нужно также задать границы видимой области. Можно просто задать viewBox снаружи, но куда удобнее вынести это в отдельные инпуты и собрать строку геттером:

  @HostBinding('attr.viewBox')  get viewBox(): string {    return `${this.x} ${this.y} ${this.width} ${this.height}`;  }

Чтобы сделать компонент интереснее, давайте добавим инпут на уровень сглаживания. Шаблон будет состоять из единственного path-элемента:

<svg:path  fill="none"  stroke="currentColor"  vector-effect="non-scaling-stroke"  stroke-width="2"  [attr.d]="d"/>

Нам нужно рассчитать атрибут d. Одним из вариантов может стать сеттер на входные данные. Но в будущем мы можем добавить другие связанные функции, например заливку под графиком или подсказки при наведении. Поэтому давайте добавим геттер, чтобы не управлять состоянием вручную:

  get d(): string {    return this.data.reduce(      (d, point, index) =>        index ? `${d} ${draw(this.data, index, this.smoothing)}` : `M ${point}`,      ""    );  }

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

Media-директива

Давайте теперь возьмемся за что-нибудь посложнее. Мы хотим научиться управлять медиаэлементами, такими как аудио- или видеотеги. И мы будем делать это с минимумом императивных вызовов. Нам нужно контролировать три вещи: текущее время, громкость и состояние воспроизведения/паузы. Все они также могут меняться нативными контролами, так что это будет двусторонний байндинг:

  @Input()  currentTime = 0;   @Input()  paused = true;   @Input()  @HostBinding("volume")  volume = 1;   @Output()  readonly currentTimeChange = new EventEmitter<number>();   @Output()  readonly pausedChange = new EventEmitter<boolean>();   @Output()  readonly volumeChange = new EventEmitter<number>();   @HostListener("volumechange")  onVolumeChange() {    this.volume = this.elementRef.nativeElement.volume;    this.volumeChange.emit(this.volume);  }

Видите @HostBinding на volume? Этого достаточно для громкости. Но не в случае с currentTime: он быстро меняется сам во время воспроизведения. Поэтому байндинг тут вызовет заикания из-за цикла постоянных обновлений. Так что заменим инпут на сеттер и будем обрабатывать только измененные значения:

  @Input()  set currentTime(currentTime: number) {    if (currentTime !== this.currentTime) {      this.elementRef.nativeElement.currentTime = currentTime;    }  }   get currentTime(): number {    return this.elementRef.nativeElement.currentTime;  }   @HostListener("timeupdate")  @HostListener("seeking")  @HostListener("seeked")  onCurrentTimeChange() {    this.currentTimeChange.emit(this.currentTime);  }

Для воспроизведения и паузы тоже создадим пару геттер/сеттер:

  @Input()  set paused(paused: boolean) {    if (paused) {      this.elementRef.nativeElement.pause();    } else {      this.elementRef.nativeElement.play();    }  }   get paused(): boolean {    return this.elementRef.nativeElement.paused;  }

Имея такую директиву, написать свой видеоплеер не составит труда:

<video  #video  media  class="video"  [(currentTime)]="currentTime"  [(paused)]="paused"  (click)="toggleState()">  <ng-content></ng-content></video><div class="controls">  <button    class="button"    type="button"    title="Play/Pause"    (click)="toggleState()"  >    {{icon}}  </button>  <input    class="progress"    type="range"    [max]="video.duration"    [(ngModel)]="currentTime"  /></div>

С помощью ng-content пользователи смогут предоставить свои видеофайлы как для нативного видеотега. А код компонента будет бессовестно краток:

  currentTime = 0;   paused = true;   get icon(): string {    return this.paused ? "\u23F5" : "\u23F8";  }   toggleState() {    this.paused = !this.paused;  }

Комбо-бокс

Теперь, когда мы настроились на нужный лад, давайте окунемся в серьезный код. Комбо-бокс куда более сложный пример, но не волнуйтесь. У нас не будет функций длиннее строки! Ну, разве что одна.

В этой части я буду полагаться на декларативный preventDefault. Он работает благодаря библиотеке ng-event-plugins, о которой я писал ранее.

Начнем с шаблона. Мы не будем создавать кастомный контрол, поскольку это отдельная тема. Вместо этого мы обернем нативный input, чтобы у пользователей был полный контроль над ним:

<combo-box [items]="items">  <input type="text" [(ngModel)]="value"></combo-box>

Внутренний шаблон будет использовать label для того, чтобы input фокусировался при клике по стрелке. Это хак! Разработчики не смогут добавить доступный лейбл, просто обернув наш компонент. Но для примера этого хватит, чтобы сэкономить время:

<label>  <ng-content></ng-content>  <div class="toggle" (mousedown.prevent)="toggle()"></div></label><div *ngIf="open" class="list" (mousedown.prevent)="noop()">  <div     *ngFor="let item of filteredItems; let index = index"    class="item"    [class.item_active]="isActive(index)"    (click)="onClick(item)"    (mouseenter)="onMouseEnter(index)"  >    {{item}}  </div></div>

Отменять действие по умолчанию для mousedown нужно, чтобы фокус не покидал поле ввода. У компонента один единственный инпут массив строк-подсказок при вводе. И в данном случае у него будет одно внутреннее состояние, которое придется контролировать вручную.

Вы можете подумать, что это состояние раскрытости выпадашки, но нет. Это индекс текущего выбранного элемента выпадашки. Мы будем использовать NaN как индикатор отсутствия текущего элемента, чтобы остаться в рамках типа number. Открытость выпадашки будет контролировать геттер на наличие выбранного элемента:

  get open(): boolean {    return !isNaN(this.index);  }

Нам нужно сузить варианты исходя из введенного пользователем текста. Мы добавим NgControl в виде @ContentChild, чтобы получить доступ к его значению. Это позволит нам отфильтровать массив:

  @ContentChild(NgControl)  private readonly control: NgControl;   get value(): string {    return String(this.control.value);  }   get filteredItems(): readonly string[] {    return this.items.filter(item =>       item.toLowerCase().includes(this.value.toLowerCase())    );  }

Теперь мы можем добавить еще один геттер для индекса элемента выпадашки:

  get clampedIndex(): number {    return limit(this.index, this.filteredItems.length - 1);  }// ... function limit(value: number, max: number): number {  return Math.max(Math.min(value || 0, max), 0);}

С ним безопасно работать, поскольку он всегда находится в рамках реально доступных индексов.

Теперь добавим обработчики событий, на которые мы подписались в шаблоне. Нам нужно открывать и закрывать список по клику на стрелку, выбирать элемент из списка и обновлять активный индекс по наведению:

  onClick(item: string) {    this.selectItem(item);  }   onMouseEnter(index: number) {    this.index = index;  }   @HostListener('keydown.esc')  @HostListener('focusout')  close() {    this.index = NaN;  }   toggle() {    this.index = this.open ? NaN : 0;  }   private selectItem(value: string) {    this.control.control.setValue(value);    this.close();  }

Теперь добавим работу с клавиатуры: по нажатию стрелок будем менять индекс, а клавишей Enter выбирать пункт. Также список будет показываться при вводе текста в поле:

  @HostListener('keydown.arrowDown.prevent', ['1'])  @HostListener('keydown.arrowUp.prevent', ['-1'])  onArrow(delta: number) {    this.index = this.open       ? limit(        this.clampedIndex + delta,         this.filteredItems.length - 1      )       : 0;  }   @HostListener('keydown.enter.prevent')  onEnter() {    this.selectItem(      this.open        ? this.filteredItems[this.clampedIndex]        : this.value    )  }   @HostListener('input')  onInput() {    this.index = this.clampedIndex;  }

Вот, в общем-то, и все. Компонент полностью работоспособен. Мы написали его в декларативном стиле, оставив только одно состояние для ручного контроля. У нас нет императивных команд вроде показать выпадашку. Вместо этого мы описали поведение компонента относительно его состояний: введенного текста, списка вариантов и активного элемента в нем. Вот почему это называется декларативным подходом.

В реальности подобный компонент требует доработки доступности. Вы можете добавить ARIA-атрибуты, такие как aria-activedescendant. Так скрин-ридеры и другие технологии доступности тоже будут в курсе активного элемента. Узнать больше про комбобокс-паттерн можно тут и тут.

Вы задумывались, отчего АК-47 является столь популярным оружием последних десятилетий? В его конструкции всего восемь подвижных частей, благодаря этому его легко производить, использовать и обслуживать. Это применимо и к архитектуре приложений: чем меньше состояний нужно обрабатывать, тем надежнее будет код. Простой дизайн надежный дизайн. И хотя декларативный код поначалу может показаться непростым, когда к нему привыкаешь начинаешь ценить его аккуратность.

Производительность

Естественный вопрос, который может возникнуть: если мы продолжаем все пересчитывать, не пострадает ли производительность нашего приложения? Разумеется, для такого подхода обязательна OnPush-стратегия проверки изменений. И, откровенно говоря, я не встречал ситуации, когда стратегия Default была бы оправдана, кроме компонента отображения ошибки в поле, поскольку на момент написания статьи в Angular так и не появился стрим на изменение touched-состояния.

Чтобы оценить производительность, давайте внимательно посмотрим на то, что мы делаем в геттерах. Конкатенация строк, как в случае с viewBox, имеет скорость порядка 1 млрд операций в секунду, или 300 млн на средненьком android-устройстве. Очевидно, это не может повредить скорости работы компонента. То же самое относится к простым арифметическим и булевым операциям.

Все становится интереснее, когда дело доходит до работы с массивами и объектами. Перебор массива из 100 элементов для поиска последнего имеет скорость 15 млн операций в секунду на моем ПК и в десять раз медленнее на смартфоне. Немутабельные операции, которые создают новые экземпляры массивов, еще медленнее. Фильтрация массива из 100 элементов выдает только 3 млн операций в секунду на компьютере и всего 300 тысяч на телефоне. Работа с созданием объектов схожий кейс за счет внутренней механики JavaScript. Можете сами оценить быстродействие вот тут. Это значит, что для того, чтобы концепция compliant-компонентов взлетела, требуется оптимизация.

Добавим простую мемоизацию, чтобы избежать лишних пересчетов. Мы можем создать декоратор для чистых методов. Он будет запоминать переданные аргументы и последний подсчитанный результат. Если аргументы не изменились, он просто вернет прошлое значение:

export function Pure<T>(  _target: Object,  propertyKey: string,  { enumerable, value }: TypedPropertyDescriptor<T>): TypedPropertyDescriptor<T> {  const original = value;   return {    enumerable,    get(): T {      let previousArgs: ReadonlyArray<unknown> = [];      let previousResult: any;       const patched = (...args: Array<unknown>) => {        if (          previousArgs.length === args.length &&          args.every((arg, index) => arg === previousArgs[index])        ) {          return previousResult;        }         previousArgs = args;        previousResult = original(...args);         return previousResult;      };       Object.defineProperty(this, propertyKey, {        value: patched      });       return patched as any;    }  };}

Таким образом мы можем переписать наш код на пары геттер + чистый приватный метод:

  get filteredItems(): readonly string[] {    return this.filter(this.items, this.value);  }   @Pure  private filter(items: readonly string[], value: string): readonly string[] {    return items.filter(item =>       item.toLowerCase().includes(value.toLowerCase())    );  }

Давайте оценим этот подход. Сравним его с неоптимизированным декларативным кодом и императивным, в котором все обновляется руками в ngOnChanges: stackblitz.com/edit/compliant-components-performance-ivy.

В этом примере список из 1000 компонентов и кнопка, которая запускает проверку изменений во всех разом. Императивные компоненты равноценны холостому прогону, ведь инпуты не меняются и ничего не происходит. Декларативные же компоненты содержат в себе несколько геттеров. Логическая проверка значения на превышение предела, конкатенация строк, математический расчет вместе с @HostBinding, вешающим класс. А также перебор массива, создание массива и создание объекта. Не забывайте, что все это умножено на 1000, ведь каждый компонент содержит эти геттеры в себе. Последний столбец в примере использует @Pure-декоратор для операций с массивами и объектами. Такие результаты я получил в среднем за 100 прогонов проверки изменений на своем компьютере:

А такие на смартфоне:

Различия на ПК укладываются в погрешность, в то время как android-девайс средней производительности выдает разницу в 10%. Можно посмотреть на это и сказать: Да оно же на 10% медленнее! Но можно взглянуть на это иначе. Даже на слабом устройстве с несколькими тысячами геттеров, считающимися одновременно, мы остаемся в пределах 60 кадров в секунду. И это всего на 1,5 миллисекунды дольше, чем холостой прогон накладные расходы от Angular.

На практике операции с DOM чаще всего могут стать бутылочным горлышком. Это самые затратные операции типового приложения. Помните байндинг геттера на класс? Фишка в том, что браузеры не будут вносить изменения в DOM, если значение осталось прежним. Это касается классов, стилей или атрибутов.

Если организовывать свое дерево компонентов разумно, применять мемоизации и использовать OnPush, декларативный подход не станет затыком производительности.

Итог

Написанные таким образом компоненты надежные и гибкие. Возможно, вам потребуется некое смещение установок в голове, чтобы освоиться с этим подходом. Но когда вы начнете мыслить декларативно, увидите, какое это удовольствие писать и поддерживать подобный код. От вас требуется быть внимательным, чтобы не допускать ошибок с производительностью. Но по своему опыту могу сказать, что оно того стоит!

Компоненты, которые мы создали, можно потыкать здесь:

Я возглавляю разработку проприетарного UI-кита в Tinkoff. Вся библиотека построена на принципах, изложенных в этой статье. Прямо сейчас библиотека в процессе выхода в open-source, и основополагающий пакет уже доступен на GitHub и npm. В нем вы найдете описанный Pure-декоратор и много других полезных низкоуровневых инструментов для создания крутых приложений на Angular. Мы обязательно расскажем про них в будущих статьях, так что до встречи!

Подробнее..

Compose. Jetpack Compose

09.10.2020 12:10:51 | Автор: admin
image

Благодаря стремительному развитию мобильной индустрии каждые несколько лет мы наблюдаем появления новых технических решений, призванных усложнить упростить жизнь разработчикам. Некоторые из них, не сыскав популярности у пользователей, остаются лишь частью истории, другие плотно укореняются в повседневной разработке, становясь в определенной области стандартом де-факто.

Пожалуй, главным трендом мобильной разработки за последние несколько лет стал декларативный UI. Такое решение уже давно успешно применяется в веб и кроссплатформенных решениях и, наконец, добралось и до нативной разработки. На iOS существует SwiftUI (представленный на WWDC 2019), а на Android Jetpack Compose (представленный месяцем ранее на Google I/O 2019). И именно о последнем мы сегодня и поговорим.

Примечание: в данной статье мы не будем рассматривать поэтапное создание первого проекта на Compose, так как этот процесс прекрасно описан в других материалах. Моя цель лишь рассказать о преимуществах и недостатках, которые дает android-разработчикам переход на Jetpack Compose, а решение использовать или нет всегда остаётся за вами.

Появление


Официальная история Jetpack Compose начинается с мая 2019, когда он был представлен публике на конференции Google I/O. Простой, реактивный и Kotlin-only новый декларативный фреймворк от Google выглядел как младший брат Flutter (который к тому моменту уже стремительно набирал популярность).

API design is building future regret

О недостатках текущего UI-фреймворка Android было сказано и написано уже достаточно большое количество раз. Проблемы с View-иерархией, зависимость от релизов платформы наличие этих и множества других мелких недостатков в той или иной мере доставляли неудобства разработчикам, что и побудило компанию Google заняться разработкой нового фреймворка, способного решить все эти проблемы.

Преимущества


Итак, чем же хорош Jetpack Compose и, главное, чем он кардинально отличается от существующего на данный момент UI-фреймворка Android?

  • Unbundled toolkit: JC не зависит от конкретных релизов платформы, а значит, забудем уже про Support Library.
  • Kotlin-only: Больше не нужно переключаться между классами и xml-файлами вся работа с UI происходит в одном Kotlin-файле.
  • Композитный подход: Наследованию нет, композиции да. Каждый UI-компонент представляет собой обычную composable-функцию, отвечающую только за ограниченный функционал, т.е. без лишней логики. Никаких больше View.java на 30 тысяч строк кода.
  • Unidirectional Data Flow: Одна из основополагающих концепций Jetpack Compose, о которой будет рассказано подробнее чуть ниже.
  • Обратная совместимость: Для использования Compose не требуется начинать проект с нуля. Имеется возможность как его встраивания (с помощью ComposeView) в имеющуюся xml-вёрстку, так и наоборот.
  • Меньше кода: Тут, как говорится, лучше один раз увидеть, чем сто раз услышать. В качестве примера возьмём классическое сочетание компонентов два поля ввода и кнопка подтверждения:

В реализации текущего UI-фреймворка вёрстка этих компонентов выглядит так:

<?xml version="1.0" encoding="utf-8"?><LinearLayout xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"    android:layout_width="match_parent"    android:layout_height="wrap_content"    xmlns:app="http://personeltest.ru/away/schemas.android.com/apk/res-auto"    android:orientation="vertical"    android:padding="@dimen/padding_16dp">    <com.google.android.material.textfield.TextInputLayout        android:id="@+id/til_login"        android:layout_width="match_parent"        android:layout_height="wrap_content"        style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"        android:hint="@string/sign_in_email"        android:layout_marginBottom="@dimen/margin_8dp">        <com.google.android.material.textfield.TextInputEditText            android:id="@+id/et_login"            android:layout_width="match_parent"            android:layout_height="wrap_content"            android:inputType="text"/>    </com.google.android.material.textfield.TextInputLayout>    <com.google.android.material.textfield.TextInputLayout        android:id="@+id/til_password"        android:layout_width="match_parent"        android:layout_height="wrap_content"        style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"        android:hint="@string/sign_in_password"        android:layout_marginVertical="@dimen/margin_8dp">        <com.google.android.material.textfield.TextInputEditText            android:id="@+id/et_password"            android:layout_width="match_parent"            android:layout_height="wrap_content"            android:inputType="textPassword"/>    </com.google.android.material.textfield.TextInputLayout>    <Button        android:id="@+id/btn_confirm"        android:layout_width="match_parent"        android:layout_height="wrap_content"        android:text="@string/sign_in_submit"        android:layout_marginTop="@dimen/margin_8dp"        android:padding="@dimen/padding_8dp"        android:background="@color/purple_700"/></LinearLayout>

В то же время, при использовании Jetpack Compose, решение будет выглядеть следующим образом:

@Preview@Composablefun LoginPage(){    var loginValue by remember { mutableStateOf(TextFieldValue("")) }    var passwordValue by remember { mutableStateOf(TextFieldValue("")) }    Surface(color = Color.White) {        Column(modifier = Modifier.padding(16.dp).fillMaxWidth()) {            Surface(color = Color.White, modifier = Modifier.padding( vertical = dimensionResource(id = R.dimen.padding_8dp))) {                OutlinedTextField(                        value = loginValue,                        onValueChange = { loginValue = it },                        label = { Text(text = stringResource(id = R.string.sign_in_email)) },                        placeholder = { Text(text = stringResource(id = R.string.sign_in_email)) },                        modifier = Modifier.fillMaxWidth()                )            }            Surface(color = Color.White, modifier = Modifier.padding( vertical = dimensionResource(id = R.dimen.padding_8dp))) {                OutlinedTextField(                        value = passwordValue,                        onValueChange = { passwordValue = it },                        label = { Text(text = stringResource(id = R.string.sign_in_password)) },                        placeholder = { Text(text = stringResource(id = R.string.sign_in_password)) },                        visualTransformation = PasswordVisualTransformation(),                        modifier = Modifier.fillMaxWidth()                )            }            Button(                    onClick = {},                    modifier = Modifier.padding( vertical = dimensionResource(id = R.dimen.padding_8dp)).fillMaxWidth(),                    backgroundColor = colorResource(R.color.purple_700)) {                Text(text = stringResource(id = R.string.sign_in_submit), modifier = Modifier.padding(8.dp))            }        }    }}

Ну и напоследок сравнительный результат:

image

Недостатки


  • Alpha-версия: Безусловно, более чем за год разработки фреймворк значительно преобразился и стал гораздо стабильнее. Однако это всё ещё альфа, а поэтому за пределами Pet-проектов использовать его не рекомендуется.

Декларативный стиль


Отдельное внимание стоит уделить главной особенности Jetpack Compose декларативному стилю создания UI. Суть подхода заключается в описании интерфейса как совокупности composable-функций (они же виджеты), которые не используют под капотом view, а напрямую занимаются отрисовкой на canvas. Для кого-то это минус, для других возможность попробовать что-то новое. Так или иначе, к концепции верстать UI кодом нативному разработчику, не работавшему ранее с аналогичными технологиями (к примеру, Flutter или React Native), придётся привыкать.

Что за Unidirectional Data Flow?


В современном android-приложении UI-состояние меняется в зависимости от приходящих событий (нажатие на кнопку, переворот экрана и т.д.). Мы нажимаем на компонент, тем самым формируя событие, а компонент меняет свой state и вызывает callback в ответ. Из-за довольно тесной связи UI-состояния с View это потенциально может привести к усложнению поддержки и тестирования такого кода. К примеру, возможна ситуация, когда помимо внутреннего state компонента, мы можем хранить его состояние в поле (например во viewmodel), что теоретически может привести к бесконечному циклу обновления этого самого state.

Что же касается Jetpack Compose, то здесь все компоненты по умолчанию являются stateless. Благодаря принципу однонаправленности нам достаточно скормить модель данных, а любое изменение состояния фреймворк обработает за нас. Таким образом, логика компонента упрощается, а инкапсуляция состояния позволяет избежать ошибок, связанных с его частичным обновлением. В качестве примера возьмем уже рассмотренный ранее composable-код. Перед описание компонентов были определены две переменные:

    var loginValue by remember { mutableStateOf(TextFieldValue("")) }    var passwordValue by remember { mutableStateOf(TextFieldValue("")) }

Мы создаем два текстовых объекта, значения которых будем устанавливать полям ввода (логина и пароля) в качестве value. А благодаря связке remember { mutableStateOf() } любое изменение значений этих объектов (из других частей кода) уведомит об этом соответствующее поле ввода, которое перерисует только значение value, вместо полной рекомпозиции всего компонента.

Вывод


Какой же вывод можно сделать о Jetpack Compose? По моему мнению, у нового решения от Google имеется огромный потенциал. С момента анонса в 2019 году была проделана огромная работа, и не менее долгий путь до релиза у фреймворка ещё впереди. Однако теперь он публично доступен, и я считаю, что это прекрасная возможность познакомиться с ним поближе. Ну а за чем, по вашему мнению, будущее пишите в комментарии, будет интересно узнать ваше мнение. Любите android!
Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru