Korzyści z DataBinding

0

Naczytałem się ostatnio, że google zaleca MVVM + DataBinding, jakie to jest fajne i w ogóle.
Po kilku próbach napisania w tym aplikacji stwierdzam że jest to mało czytelne i tylko wydłuża proces pisania aplikacji.
Zgadzam się ze MVVM ma sens, ale nie widzę absolutnie korzyści z DataBinging.

Jakie korzyści ma DataBinding i jakie mamy alternatywy żeby pisanie było przyjemne ?

1

Data binding to implementacja wzorca Obserwator i ma takie same korzyści jak ten wzorzec. W praktyce wielu programistów nie korzysta z data binding ale z wzorca Obserwator np. z użyciem RxJava korzystają jak najbardziej.

0

Serio dla ciebie bardziej czytelne jest pisanie wszędzie findViewById? A jak masz np ustawić wartości 20 pól edycyjnych odpowiednio na teksty pobrane z pól obiektu klasy + zmiany wartości pól klasy gdy użytkownik coś wpisze, to chcesz klepać ~50 linii gównokodu (get/setText, findviewById) zamiast zbindować kontrolki okna z klasą?

Co jest przyjemnego w Ctrl+C i Ctrl+V?

1

DataBinding robi jedną rzecz dobrze - wystawia klasę, która jest tożsama z layoutem w XMLu. Robi to dobrze dlatego, że całość jest trzymana w jakimś jednym konterze i obsługuje różne warinaty layoutu, biorąc pod uwagę czy kontrolki występują tam czy nie i dodaje adnotację @Nullable albo robi typ nullowalny w Kotlinie. I w zasadzie tyle dobrego można powiedzieć o DataBinding. Reszta, to nieskalowalne koncepcje. Tyle dobrego, że DataBinding jest dostępny tylko jako zewnętrzna zależność i nie jest wbudowany we framework jak np. Fragmenty czy Loadery.

Także moja rada - trzymaj się od tego z dala. Jak chcesz obserwować wartości to lepiej użyć np. LiveData albo RxJavy i łączyć klasy w kodzie a nie w XMLu.

1
Kulson napisał(a) w komentarzu:

No tak, ale ciągle nie rozumiem, czemu wolisz unikać łączenia w xml-u i łączyć wszystko w kodzie. Podejście takie jak w .net, jak pokazuje praktyka, jako tako się sprawdza

Kluczowe jest tutaj "jako tako". Widoki zazwyczaj zaczynają się prosto i logiki jest około 0. W takim wypadku binding w XMLu faktycznie może wydawać się atrakcyjny, bo nie musimy pisać praktycznie żadnego kodu tylko dwie czy trzy linijki konfiguracyjne i wszystko działa. Problem jaki tutaj widzę, to binding jest przerostem formy nad treścią, bo widok jest na tyle prosty, że nie trzeba używać mechanizmu bindingu. Ale jeżeli ktoś chce, to niech używa.

Niemniej, widoki mają to do siebie, że dochodzą do nich nowe rzeczy od klienta. Póki jest to dodanie jakiegoś pola tekstowego itp. to dalej jest fajnie. Kiedy dochodzi skomplikowana logika, a zazwyczaj tak jest, to XML jest okropnym miejscem na to. Wpędzamy się wtedy w kozi róg z ograniczeniami jakie narzuca DataBinding (mam na myśli bibliotekę od Googla a nie wzorzec).

Dla zobrazowania przykład. Jestem teraz na boku w krótkim, 2 tygodniowym projekcie. W dużym skrócie polega on na przesłaniu danych do iPada bez użycia Internetu i jakichkolwiek kabli (ilość danych od setek MB to kilku GB). Miało być prosto z kilkoma przyciskami i kilkoma ekranami. W tej chwili wszystko jest na jednym ekranie z dwoma przyciskami (start i stop), jednym polem tekstowym i jedenym paskiem postępu. Logika wygląda mniej więcej tak.

  • Sprawdź czy jest coś w ogóle do przesłania.
  • Utwórz na Androidzie hotspot, jeżeli jakiś jeszcze nie istnieje.
  • Wyświetl SSID i hasło użytkownikowi.
  • Poczekaj na podłączenie iPada do Wifi.
  • Utwórz socket do przesyłania danych.
  • Poczekaj na podłączenie iPada do socketa.
  • Spakuj wszystkie pliki w jeden.
  • Wyślij plik po sockecie.
  • Usuń pliki wraz ze spakowanym plikiem z urządzenia.

Przycisk "start" rozpoczyna odpowiednią akcję w odpowiednim momencie maszyny stanu. Jeżeli jest hotspot to nie twórz, jeżeli pliki są już spakowane to nie pakuj, itd. Pole tekstowe wyświetla odpowiedni stan maszyny i w przypadku pakowania postęp pakowania, w przypadku przesyłania postep przesyłania. Teraz sytuacje błędogenne.

  • Hotspot się nie włączył -> odpowiedni komunikat.
  • Hotspot umarł -> odpowiedni komunikat.
  • iPad odłączył się od hotspota -> odpowiedni komunikat.
  • Socket umarł -> odpowiedni komunikat.
  • Problem z plikami -> odpowiedni komunikat.
  • Problem z przesyłem -> odpowiedni komunikat.
  • Brak jakichkolwiek plików do spakowania -> odpowiedni komunikat.

Na koniec gwóźdź do trumny - dodaj sterowanie głosem z odpowiednią wizualiazją tego na ekranie. Jeżeli ktoś coś powiedział, to pokaż mu co powiedział. Jeżeli sterowanie głosem jest włączone to blokuj odpowiednie przyciski. W tle cały czas działa kamera, której trzeba blokować akcje aktywnego kręcenia wideo i robienia zdjęć (ale nie nie pasywnego kręcenia w tle). Też sterowana głosowo.

Wolałbym sobie strzelić w łeb niż mieć to w XMLu. Natomiast dałbym się zabić za generowanie UiBindera z XMLa bez konieczności DataBinding. Samemu brakuje mi niestety chęci i czasu, żeby to napisać.

1

Hmm no ale tylko szaleniec miałby całą logikę w xml-u. Możesz przecież mieć odpowiednie klasy, a w xml-u tylko odwołania do pól lub getterów/setterów. Czyli odwołanie do gettera zwraca np boolean i ta wartość jest używana do ustawienia przycisku na enabled/disabled. Jeśli masz sensownie nazwy metod, to wystarczy zajrzeć w xml i widzisz co się dzieje w aplikacji, jeżeli wiążesz wszystko w kodzie, to musisz analizować 10x więcej kodu w wielu plikach, dlatego mi się to wydaje mniej przejrzyste.

Chodzi mi o to, że dalej cała logika jest w kodzie, a w xml tylko krótkie odwołania do metod modelu. Kod osadzany w xml (co jest jak pisałem, niedozwolone w .net), jest dobry tylko do rzeczy typu "ukryj jedną kontrolkę, gdy inna nie zawiera tekstu", w .net do tego potrzeba oddzielnego konwertera, co jest raczej przetostem formy nad treścią

0

Chyba się nie do końca jasno wyraziłem. Nie miałem na myśli logiki aplikacji w XMLu. To już w ogóle byłaby jakaś paranoja. Miałem na myśli logikę sterowania widokiem. Załóżmy, że mam poniższe dwie klasy. UiModel jest jaki jest od biedy, bo w Javie nie ma sealed class a nie chce mi się tworzyć ani typów unijnych dla przykładu ani korzystać ze wzorca odwiedzającego. Dlatego, niech będzie to cholerne @Nullable i powiedzmy, że nie korzystam tutaj z Option z lenistwa.

final class UiModel {
  final State state;
  final boolean isVoiceCommandEnabled;
  @Nullable final HotSpot hotspot;
  @Nullable final Integer progress;
  @Nullable final Error error;

  UiModel(
      State state,
      boolean isVoiceCommandEnabled,
      @Nullable HotSpot hotspot,
      @Nullable Integer progress,
      @Nullable Error error
  ) {
    if (state == ZIPPING_FILES || state == TRANSFERRING_FILES) {
      requireNonNull(progress, "Progress must have value for zipping or transfer");
    }
    if (state != IDLE && state != ENABLING_HOTSPOT) {
      requireNonNull(hotspot, "Hotspot must be configured");
    }
    this.state = state;
    this.isVoiceCommandEnabled = isVoiceCommandEnabled;
    this.hotspot = hotspot;
    this.progress = progress;
    this.error = error;
  }

  enum State {
    IDLE(R.string.start_transfer_info, R.string.empty),
    ENABLING_HOTSPOT(R.string.enabling_hotspot, R.string.empty),
    AWAITING_IPAD(R.string.transfer_file_pending, R.string.empty),
    ZIPPING_FILES(R.string.transfer_file_pending, R.string.zipping_files),
    TRANSFERRING_FILES(R.string.transfer_file_pending, R.string.transferring_files),
    SUCCESS(R.string.transfer_file_done, R.string.empty);

    @StringRes final int transferInfo;
    @StringRes final int progressInfo;

    State(@StringRes int transferInfo, @StringRes int progressInfo) {
      this.transferInfo = transferInfo;
      this.progressInfo = progressInfo;
    }
  }

  enum Error {
    NO_FILES(R.string.no_files_to_transfer),
    HOTSPOT(R.string.hotspot_error),
    TRANSFER(R.string.transfer_file_error);

    @StringRes final int transferInfo;

    Error(@StringRes int transferInfo) {
      this.transferInfo = transferInfo;
    }
  }
}
final class UiBinder {
  private final TextView info;
  private final TextView ssid;
  private final TextView password;
  private final TextView progressInfo;
  private final ProgressBar progressBar;
  private final Button startTransfer;
  private final Button stopTransfer;

  UiBinder(View view) {
    info = view.findViewById(R.id.transfer_files_info);
    ssid = view.findViewById(R.id.ssid);
    password = view.findViewById(R.id.password);
    progressInfo = view.findViewById(R.id.progress_info);
    progressBar = view.findViewById(R.id.transfer_files_progress);
    startTransfer = view.findViewById(R.id.start_transfer_button);
    stopTransfer = view.findViewById(R.id.stop_transfer_button);
  }

  void renderUi(UiModel uiModel) {
    startTransfer.setEnabled(enableStartButton(uiModel));
    stopTransfer.setEnabled(enableStopButton(uiModel));
    if (uiModel.hotspot != null) {
      ssid.setText(uiModel.hotspot.ssid);
      password.setText(uiModel.hotspot.password);
    }
    if (hasError(uiModel)) {
      renderError(uiModel);
    } else {
      renderMachineState(uiModel);
    }
  }

  private void renderMachineState(UiModel uiModel) {
    info.setText(uiModel.state.transferInfo);
    progressInfo.setText(uiModel.state.progressInfo);
    if (uiModel.state.progressInfo != R.string.empty) {
      requireNonNull(uiModel.progress);
      progressBar.setProgress(uiModel.progress);
    }
  }

  private void renderError(UiModel uiModel) {
    requireNonNull(uiModel.error);
    progressInfo.setText(R.string.empty);
    progressBar.setProgress(0);
    info.setText(uiModel.error.transferInfo);
  }

  private boolean enableStartButton(UiModel uiModel) {
    return !uiModel.isVoiceCommandEnabled && (hasError(uiModel) || isIdleOrSuccess(uiModel));
  }

  private boolean enableStopButton(UiModel uiModel) {
    return !uiModel.isVoiceCommandEnabled && !hasError(uiModel) && !isIdleOrSuccess(uiModel);
  }

  private boolean isIdleOrSuccess(UiModel uiModel) {
    return uiModel.state == IDLE || uiModel.state == SUCCESS;
  }

  private boolean hasError(UiModel uiModel) {
    return uiModel.error != null;
  }
}

Dla uzupełnienia jeszcze klasa HotSpot.

public final class HotSpot {
  public final String ssid;
  public final String password;
  public final String ip;

  HotSpot(String ssid, String password, String ip) {
    this.ssid = ssid;
    this.password = password;
    this.ip = ip;
  }
}

Jak wyglądałby odpowiednik tego wszystkiego w XMLu?

Zastanawia mnie też, jak inne rzeczy można zrobinć z DataBinding.

Jak XML radzi sobie z animacjami? Jeżeli chciałbym, żeby mój przycisk przechodząc w disabled powoli stawał się przeźroczysty a potem zmieniał widoczność na View.GONE, to dałoby się to zrobić? Tutaj odpowiedź jest prosta - dodać w odpowiednim miejscu w kodzie.

Jak radzić sobie, gdy ten sam XML ma być inaczej obsługiwany w innym miejscu aplikacji? Tu nie ma "łatwego" rozwiązania, ale UiModel można ugenerycznić. Można go też odciąć od zasobów aplikacji, stworzyć jakiś UiRenderer dla UiBinder i zmienić widoczność kontrolek, żeby nie były private. Część wspólna dla renderowania pozostanie taka sama, a reszta może być dostosowana.

0

No z tego co widzę, robisz wszystko na piechotę, gdy DataBinding sam generuje klasę na podstawie layoutu, masz większą kontrolę kosztem klepania większej ilości boilerplate, bo twoje UiBinder to w większości taki kod.

Co do wykorzystania tego samego xml w różny sposób, to rozumiem, że chcesz bindować ten sam layoyt z różnymi modelami? Nie przychodzi mi do głowy sytuacja, gdzie byłoby to potrzebne

0

Tak, UiBinder to niestety boilerplate. Mówiłem już, że akurat to jest dobre w DataBinding i tego mu zazdroszczę. Poza tym dużo tego kodu nie ma - raptem kilkadziesiąt linii, które łatwo wyskalować do innych rozwiązań w razie potrzeby. Bo boilerplate do dokładnie ta część. Reszta tak czy inaczej musi być przenisiona do XMLa.

final class UiBinder {
  private final TextView info;
  private final TextView ssid;
  private final TextView password;
  private final TextView progressInfo;
  private final ProgressBar progressBar;
  private final Button startTransfer;
  private final Button stopTransfer;
 
  UiBinder(View view) {
    info = view.findViewById(R.id.transfer_files_info);
    ssid = view.findViewById(R.id.ssid);
    password = view.findViewById(R.id.password);
    progressInfo = view.findViewById(R.id.progress_info);
    progressBar = view.findViewById(R.id.transfer_files_progress);
    startTransfer = view.findViewById(R.id.start_transfer_button);
    stopTransfer = view.findViewById(R.id.stop_transfer_button);
  }
}

Moje pytanie brzmiało jak wyglądałoby przenisienie metody renderUi(UiModel uiModel) do XMLa. UiModel może oczywiście inaczej wyglądać, jeżeli tak by było wygodniej w XMLu.

Nie jest trudno mi sobie wyobrazić sytuację dwóch różnych modeli. Dla przykładu powyżej powiedzmy, że przesyłamy na chmurę i nie zipujemy plików. Widok w zasadzie ten sam. Model ma za to informację o tym ile plików już udało się przesłać i jaki jest procent całości.

Pytanie o animacje, poważnie mnie interesuje. Da się to obsłużyć w XMLu?

0

Trzeba by było pogłówkować, ale zastanawia mnie, czemu by nie obsłużyć wszystkich przypadków w jednym modelu. Założenie jest takie, że jeden layoyt (fragment lub activity) jest zbindowane z jednym modelem, na tej podstawie jest generowany BR

0

Nie powinno być wszystko zamknięte w jedyn modelu, bo to zupełnie osobny przypadek użycia. Gdyby wszystko wrzucić do jednego worka, musiałbym zadbać o to, żeby nie tworzyć błędnych modeli dla złego przypadku i napisać dla tych rzeczy testy. Dla osobnych modeli nie muszę tego robic, bo kompliator i system typów gwarantują mi, że niczego nie skopałem.

Dla każdego przypadku mogę mieć sobie osobne moduły w Gradlu. Jeden który zipuje i przesyła pliki po WiFi. Inny, który wrzuca pliki do chmury bez zipowania. I jeszcze inny, który przesyła po Bluetoothie albo Wifi, ale nie korzysta w ogóle z komend głosowych (bo akurat urządzenie, które ma wbudowaną obsługę komend głosowych nie ma Bluetootha, a dla zwykłych telefonów obsługa głosowa nas nie obchodzi).

0

No to jedyne, co mi przychodzi do głowy, to zrobić jedną klasę - wrapoera, którą zbindujemy z xml-em i to ona będzie decydować, który pojo będzie fizycznie obserwowany, a w xml definicja bindingu byłaby stała, do klasy wrappera.

Albo zdefiniować w xml kilka obiektów variable, w tym jeden przechowujący kontekst, na podstawie którego można by w xml decydować, z którym viewmodelem bindować konkretną kontrolkę. Z tym, że to drugie to tylko strzelam, bo nie próbowałem nigdy definiować wielu viewmodeli w jednym xml. Ale na SO coś piszą, że można kilka: https://stackoverflow.com/questions/47820419/is-it-possible-to-bind-two-variables-in-xml

[edit]
Jeśli by się dało w xml-u zweryfikować, czy dany model jest rzeczywiście używany w bieżącym kontekście (pozostałe byłyby np nullami), to nawet variable context stałby się zbędny.

Ja bym kombinował coś w tym kierunku, ale jako że w tej chwili nie mam dostępu do komputera, tylko gdybam teoretycznie

0

No i to są m.in. nieskalowalne rzeczy, o których piszę. Oba rozwiązania wymagają, żeby prawie wszystko było w jednym module i dodatkowo, żeby klasy, które zupełnie nic nie muszą o sobie wiedzieć, były gdzieś powiązane. Dodatkowo, wpychamy coraz więcej skomplikowanej logiki do XMLa, którą ciężko przetestować. A potem przychodzi strona biznesowa i mówi "Jednak chcemy, żeby te dwa widoki wyglądały zupełnie inaczej".

Także w moim odczuciu DataBinding sprawdza się przy małych rzeczach typu strona profilowa i jest tam zupełnie zbędny.

0

@Michał Sikora: Mógłbyś powiedzieć jak wygląda wykorzystanie enum State razem z SOLID'owym Interface segregation principle? Pytam się w kontekście progressInfo, dla niektórych stanów podawany jest pusty string z czego wnioskuję że taki stan nie ma progresu - jeśli nie ma progressu to dlaczego w ogóle jest typu "Progressable" (taki wniosek można wysnuć z kodu). Mógłbyś jeszcze napisać co jest odpowiedzialne za ustawienie odpowiedniego stanu - kiedy / co wywołuje renderUi?

0

Niestety niektóre stany mają nadmiarowe dane, bo łatwo się tego nie da uniknąć w Javie (zwłaszcza na Androida). Najszybsze rozwiązanie to takie, żeby skorzystać z jakiegoś kontenera typu Option i wtedy nie trzeba używać R.string.empty tylko Option.none(). Można ewnetualnie zrobić @StringRes @Nullable final Integer progressInfo, ale najzwyczajniej nie chce mi się użerać podczas bindowania z tym.

if (uiModel.state.progressInfo == null) {
  progressInfo.setText(null);
} else {
  progressInfo.setText(uiModel.state.progressInfo);
}  

Nie mogę zrobić po prostu progressInfo.setText(uiModel.state.progressInfo), bo zostanie użyta zła przeciążona metoda i unboxing wywali apkę. Ale jest to i tak półśrodek, bo dalej jakaś informacja o progresie jest.

Ten UiModel ma też inny problem, bo stany które nie powinny nieść ze sobą informacji np. o hotspocie albo wartości progresu, tę wartość niosą. Tutaj Java staje się już bardzo brzydka, bo albo akceptujemy, że te wartości opcjonalnie są, albo piszemy trochę boilerplatu i tworzymy typy unijne, albo korzystamy ze wzorca odwiedzającego. Wtedy informację o hotspocie niósłby tylko stan AWAITING_IPAD.

W aplikacji renderowanie jest używane w ten sposób.

disposables.add(uiBinder.startTransferClicks()
  .compose(presenter::processEvents)
  .scan(UiModel.createIdle(), this::toModel)
  .observeOn(AndroidSchedulers.mainThread())
  .subscribe(uiBinder::renderUi, RxUtil::rethrow));

// Sygnatury ważniejszych metod
Observable<Object> startTransferClicks()

Observable<Either<EnableHotspotResult, TransferFilesResult>> processEvents(Observable<Object> events)

UiModel toModel(UiModel previous, Either<EnableHotspotResult, TransferFilesResult> result)

Gdyby w UI był więcej niż jeden typ zdarzenia, to zamiast uiBinder.startTransferClicks() można stworzyć połączony strumień różnych zdarzeń we własny typ i przekazać go do prezentera. Tak naprawdę już tutaj powinienem stworzyć własny typ TransferFilesEvent.

W processEvents prezenter korzysta z różnych operacji na strumieniach, żeby odpowiednio przetwarzać dane i obsługiwać wszystkie sytuacje błędu - brak plików, nagłe wyłączenie hotspota, niemożność włączenia hotspota itd. Łącznie około 20 linii kodu do samego łączenia strumieni. Biorąc pod uwagę jak skomplikowany jest cały przypadek użycia, myślę że wynik jest zadowalający.

toModel po prostu mapuje wyniki z prezentera na UiModel i ma dostęp do poprzednio ustawionego modelu gdybyśmy potrzebowali jakichś poprzednich danych. Żeby przyjemniej się mapowało, to UiModel ma na sobię metodę UiModel.Builder toBuilder(). Teoretycznie boilerplate, ale wystarczy użyć AutoValue (albo Lomboka jeżeli ktoś siebie nienawidzi).

To wyżej to trochę kłamsto, bo nie mam w tej aplikacji prezentera tylko wszystko jest tworzone i łączone w onCreate, ale nie ma problemu, żeby taki prezenter stworzyć. I żeby nie być gołosłownym, to w moim głównym projekcie stosuję to podejście z prezeneterami.

Natomiast w Kotlinie dużo upierdliwości znika bo mamy data class z copy(), sealed class, typealias i korutyny.

1 użytkowników online, w tym zalogowanych: 0, gości: 1