środa, 27 lipca 2016

Xamarin.Android kontra Java odc.2 (narzędzia, składnia, async, synchronizacja, Java i C#, audio w tle, grafika 2D, własny widok, Visualizer, Broadcast Receiver, LocalBroadcastManager, stylowanie CardView)

Hurra!  “Kolekcjonerska” edycja mojej app-ki w Xamarin.Android uzyskała właśnie taką funkcjonalność jak oryginał w Javie po odcinku 9 z DSP. Sprawdzajcie na githubie. To większość kodu, do przeportowania pozostał jedynie uzupełniający materiał z odcinka 10.

Screenshot_20160727-195213

Ponieważ portuję swój własny kod sprzed paru miesięcy, nie chciałbym się powtarzać, jeśli chodzi o sprawy związane ściśle z samym Androidem, które już opisywałem podczas DSP. Zajmiemy się więc bardziej wysublimowanymi sprawami związanymi z samym Xamarinem lub stykiem Android-Xamarin.

#1  Target API Level

Rzecz niby banalna, a trochę dała mi w kość w Visual Studio 2015. Przy ustawieniu docelowego targetu na API 23 z Android 6 dostawałem najpierw komunikat z ostrzeżeniami o wersji Javy. Postąpiłem zgodnie ze stackoverflow i doinstalowałem Javę 8.0 SDK (x86). Potem w ustawieniach ogólnych Xamarin (Tools –> Options –> Xamarin) w Visual Studio zmieniłem ścieżkę na tę wersję. Wtedy dostałem ostrzeżenie, że wersja z manifestu Android jest starsza niż TargetFramework!!! i że do kompilacji i tak zostanie użyta najnowsza wersja API, czyli 24 z nadchodzącego Android 7 (może dlatego wcześniej krzyczało o nowszą Javę niż 7 instalowana domyślnie z VS 2015 U3).  A wszystko miałem ustawione jak należy. W końcu wpadłem na pomysł, by ręcznie przedytować plik .csproj i parametr AndroidUseLatestPlatformSdk ustawiłem na False (pozostawał ciągle na True w XML mimo wybrania z comba w VS konkretnej wersji SDK). Pomogło!

#2 Klasy zagnieżdżone z akcjami

Obserwując różne sample Xamarin wyczaiłem lepszy niż ostatnio pattern do portowania klas callbacków z Javy (czy broadcast receiverów). Otóż aby nie przekazywać jawnie referencji do obiektu klasy rodzica wzbogacamy callback o akcje wywoływane przez jego metody. Natomiast kod akcji ustawiamy w klasie rodzica, przeważnie w jej konstruktorze lub metodzie tworzącej np. OnCreate.

    public abstract class BaseActivity: AppCompatActivity, …
    {          

        class Callback : MediaControllerCompat.Callback
        {
            public Action<PlaybackStateCompat> OnPlaybackStateChangedImpl { get; set; }
            public Action<MediaMetadataCompat> OnMetadataChangedImpl { get; set; }

            public override void OnPlaybackStateChanged(PlaybackStateCompat state)
            {
                OnPlaybackStateChangedImpl(state);
            }

            public override void OnMetadataChanged(MediaMetadataCompat metadata)
            {
                OnMetadataChangedImpl(metadata);
            }
        }

        readonly Callback mediaControllerCallback = new Callback();

        …


        protected override void OnCreate(Bundle savedInstanceState)
        {
            base.OnCreate(savedInstanceState);

            …

            mediaControllerCallback.OnPlaybackStateChangedImpl = (state) =>
            {
                if (ShouldShowControls)
                {
                    ShowPlaybackControls();
                }
                else
                {
                    LogHelper.Debug(Tag, "mediaControllerCallback.onPlaybackStateChanged: " +
                            "hiding controls because state is ", state.State);
                    HidePlaybackControls();
                }
            };

            mediaControllerCallback.OnMetadataChangedImpl = (metadata) =>
            {
                if (ShouldShowControls)
                {
                    ShowPlaybackControls();
                }
                else
                {
                    LogHelper.Debug(Tag, "mediaControllerCallback.onMetadataChanged: " +
                        "hiding controls because metadata is null");
                    HidePlaybackControls();
                }
            };

        }                                
               
    }

#3 Asynchroniczne wywołania

Marzy mi się API Android z async-ami we wszystkich trwających dłużej metodach, tak jak mamy w WinRT API w Windows 8.x/10.  Póki co na pewno async możemy stosować przy kliencie Http z .NET. Przykładem niech będzie metoda z klasy BitmapHelper z przykładu MediaBrowserService (będącego portem z oryginału w Javie z Android SDK):

        public async static Task<Bitmap> FetchAndRescaleBitmap(string uri, int width, int height)
        {
            try
            {
                using (var client = new HttpClient())
                {
                    Stream stream = await client.GetStreamAsync(uri);
                    int scaleFactor = FindScaleFactor(width, height, stream);
                    LogHelper.Debug(Tag, string.Format("Scaling bitmap {0} by factor {1} to support {3}x{4} requested dimension.",
                        uri, scaleFactor, width, height));
                    return Scale(scaleFactor, stream);
                }
            }
            catch (IOException e)
            {
                throw e;
            }
        }

Później jednak autor płynnie przechodzi z kodu z async na kod bez async:

public void Fetch(string artUrl, FetchListener listener)
        {
           …

            Task.Run(async () => {
                Bitmap[] bitmaps;
                try
                {
                    Bitmap bitmap = await BitmapHelper.FetchAndRescaleBitmap(artUrl, MaxArtWidth, MaxArtHeight);
                    Bitmap icon = BitmapHelper.Scale(bitmap, MaxArtWidthIcon, MaxArtHeightIcon);
                    bitmaps = new[] { bitmap, icon };
                    cache.Put(artUrl, bitmaps);
                }
                catch (IOException)
                {
                    return null;
                }
                LogHelper.Debug(Tag, "doInBackground: putting bitmap in cache. cache size=" + cache.Size());
                return bitmaps;
            }).ContinueWith((antecedent) => {
                var bitmaps = antecedent.Result;
                if (bitmaps == null)
                    listener.OnError(artUrl, new ArgumentException("got null bitmaps"));
                else
                    listener.OnFetched(artUrl, bitmaps[BigBitmapIndex], bitmaps[IconBitmapIndex]);
            }, TaskContinuationOptions.OnlyOnRanToCompletion);
        }

Ja z kolei odczyt utworów z Music Library w klasie MusicProvider umieściłem w innym wątku:

public void RetrieveMediaAsync(Context context, Action<bool> callback)
        {
            LogHelper.Debug(Tag, "retrieveMediaAsync called");
            if (currentState == State.Initialized)
            {
                callback?.Invoke(true);
                return;
            }

            Task.Run(() =>
            {
                try
                {
                    if (currentState == State.NonInitialized)
                    {
                        currentState = State.Initializing;

                        var projection = new string[]
                        {
                            MediaStore.Audio.Media.InterfaceConsts.Id,
                            MediaStore.Audio.Media.InterfaceConsts.Artist,
                            MediaStore.Audio.Media.InterfaceConsts.Title,
                            MediaStore.Audio.Media.InterfaceConsts.Duration,
                            MediaStore.Audio.Media.InterfaceConsts.Data,
                            MediaStore.Audio.Media.InterfaceConsts.MimeType
                        };

                        var selection = MediaStore.Audio.Media.InterfaceConsts.IsMusic + "!= 0";
                        var sortOrder = MediaStore.Audio.Media.InterfaceConsts.DateAdded + " DESC";

                        var cursor = context.ContentResolver.Query(MediaStore.Audio.Media.ExternalContentUri, projection, selection, null, sortOrder);

                        if (cursor != null && cursor.MoveToFirst())
                        {
                            do
                            {
                                int idColumn = cursor.GetColumnIndex(MediaStore.Audio.Media.InterfaceConsts.Id);
                                int artistColumn = cursor.GetColumnIndex(MediaStore.Audio.Media.InterfaceConsts.Artist);
                                int titleColumn = cursor.GetColumnIndex(MediaStore.Audio.Media.InterfaceConsts.Title);
                                int durationColumn = cursor.GetColumnIndex(MediaStore.Audio.Media.InterfaceConsts.Duration);
                                int filePathIndex = cursor.GetColumnIndexOrThrow(MediaStore.Audio.Media.InterfaceConsts.Data);

                                var id = cursor.GetLong(idColumn).ToString();

                                var item = new MediaMetadataCompat.Builder()
                                        .PutString(MediaMetadataCompat.MetadataKeyMediaId, id)
                                        .PutString(CustomMetadataTrackSource, cursor.GetString(filePathIndex))
                                        .PutString(MediaMetadataCompat.MetadataKeyArtist, cursor.GetString(artistColumn))
                                        .PutString(MediaMetadataCompat.MetadataKeyTitle, cursor.GetString(titleColumn))
                                        .PutLong(MediaMetadataCompat.MetadataKeyDuration, cursor.GetInt(durationColumn))
                                        .Build();

                                musicListById.Add(id, new MutableMediaMetadata(id, item));

                            } while (cursor.MoveToNext());
                        }

                        currentState = State.Initialized;
                    }
                }
                finally
                {
                    if (currentState != State.Initialized)
                    {
                         currentState = State.NonInitialized;
                    }
                }
            }).ContinueWith((antecedent) => {
                callback?.Invoke(currentState == State.Initialized);
            }, TaskContinuationOptions.OnlyOnRanToCompletion);           
        } 

Brakuje według mnie dobrego wykładu, kiedy stosować async w Xamarin, kiedy wybrać Task.Run, a kiedy coś innego… Chyba że jeszcze nie dotarłem do takich materiałów, w każdym razie w podstawowej dokumentacji głównie jest tłumaczone czym w ogóle async w C# jest. Pamiętam, że gdzieś ktoś opisał, jak async został zaimplementowany w Xamarin… Przydałoby się to odszukać i przeprowadzić małe dochodzenie…    

#4 Synchronizowane kolekcje

Synchronizowane kolekcje, kolejny stosunkowo mało nagłaśniany temat. W Javie ktoś napisze  Collections.synchronizedList(…) i jak to przetłumaczyć na C#?  Co prawda mamy w .NET trochę synchronizowanych kolekcji, zwłaszcza od .NET 4.0 pojawiły się dość nowoczesne bardziej wydajne byty, nie bazujące tylko na lock-ach. Ale jeśli potrzebujemy indeksowanego dostępu jak w liście, to akurat tego … nie mamy. Tłumacząć klasę QueueManager użyłem w końcu kodu z klasy SynchronizedList ze źródeł Xamarin.Forms…  Ale to jest klasa internal, w dodatku jej implementacja bazuje na lock-ach. Czemu w .NET nie ma takiej gotowej klasy, albo w Xamarin?

#5 Elementy Javy i  Java-C#

Brakuje mi jakiejś klasy czy konstrukcji z Javy. Co robię? Tworzę odpowiedniki w C# lub … wywołuję elementy Javy z dodatkowych przestrzeni nazw….  Tłumacząc LightOrganProcessor w pewnym momencie potrzebowałem wywołać System.currentTimeMillis(). Mógłbym to zrobić pisząc Java.Lang.JavaSystem.CurrentTimeMillis(), ale zauważyłem że ktoś w jednym z przykładów dla Xamarin napisał ręcznie w czystym C# odpowiednik:

var beginningOfTime = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);

long systemTimeStartSec = (long)(DateTime.UtcNow - beginningOfTime).TotalMilliseconds;

Nie zauważyłem różnic pomiędzy jednym a drugim sposobem, ale może w C# powinniśmy używać jak najwięcej C# by unikać narzutu komunikacji między dwiema maszynami wirtualnymi…

A teraz rzecz, na której straciłem kilka godzin, zanim odkryłem co nie działało. Serwis Androidowy nie dawał mi znaku życia i żadnych informacji o błędach czemu nie wstawał. Okazało się, że nie tworzyłem w BaseActivity obiektu klasy ComponentName w należyty sposób. Co prawda podawałem nazwę ala Java ale w stringu, może nie był do końca dobry, albo ktoś tego inaczej nie przewidział. W każdym razie wzorowany na jednym samplu poprawny sposób na ComponentName wymagający obiektu class Javy:

mediaBrowser = new MediaBrowserCompat(this,
                new ComponentName(this, Java.Lang.Class.FromType(typeof(MusicService))), connectionCallback, null);

#6 Fragmenty i własne widoki

O ile w Javie jak jest pakiet, to wszystko w nim jest, o tyle w projekcie w Xamarin jest pewna dowolność. Nazwa pakietu nie musi zgadzać się z .NET-ową przestrzenią nazw w klasach aplikacji (w generowanym przez Xamarin manifeście - jak podejrzałem - są wstawiane dość długie unikalne ciągi znaków przed nazwami klas). W moim przypadku przy wstawianiu fragmentu do XML musiałem zmienić jego nazwę, by pasowała do faktycznego namespace w C# (miałem wyjątek przy otwieraniu widoku aplikacji):

<fragment
            android:name="LightOrganApp.Droid.UI.PlaybackControlsFragment"
            android:id="@+id/fragment_playback_controls"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_alignParentBottom="true"
            tools:layout="@layout/fragment_playback_controls" />

Podobnie było z własnym widokiem:

<LightOrganApp.Droid.UI.CircleView
       android:id="@+id/bass_light"
       android:layout_width="match_parent"
       android:layout_height="0dp"
       android:layout_weight="1"
       app:circleColor="#d50000"/>

#7 Wyjątki

Zauważyłem po samplach, że można łapać wyjątki systemowe z Javy, ale tam, gdzie da się łapać wyjątek w .NET to taki jest wtedy łapany.

#8 Stylowanie CardView

Potrzebowałem zrobić sobie zielony dolny pasek. U mnie jest to fragment hostowany w CardView. Tło było widoczne z CardView, to więc ostylowałem. Jak? Dodałem definicję stylu w values/styles.xml:

<style name="AppTheme.CardView" parent="Theme.AppCompat">   
       <item name="cardBackgroundColor">#ff1b5e20</item>
   </style>

Po czym zaaplikowałem ten styl w miejscach użycia CardView:

<android.support.v7.widget.CardView
           android:id="@+id/controls_container"
           android:layout_width="match_parent"
           android:layout_height="wrap_content"
           android:layout_gravity="bottom"
           app:cardElevation="8dp"
           style="@style/AppTheme.CardView">
           <fragment
               android:name="LightOrganApp.Droid.UI.PlaybackControlsFragment"
               android:id="@+id/fragment_playback_controls"
               android:layout_width="match_parent"
               android:layout_height="wrap_content"
               android:layout_alignParentBottom="true"
               tools:layout="@layout/fragment_playback_controls" />
       </android.support.v7.widget.CardView>

Uwaga jeszcze do designera plików .axml w Visual Studio 2015.  Czemu po każdym załadowaniu widoku do designera plik XML ulega edycji, mimo że nic w nim jeszcze nie zmieniłem?

#9 Zagadka z wizualizacji

Największa zagadka wszech czasów przede mną do rozwiązania. Czemu ten sam z pozoru kod w Javie powoduje ładne błyski świateł w rytm muzy, natomiast w Xamarinie… błyska trochę co najwyżej pierwsze światło, a reszta w zasadzie nie mruga? Porównywałem już parę razy kod oryginału z portowanym i póki co nie wiem. Powstaje pytanie czy to nie jest objaw mniejszej wydajności, chyba że wrapper Xamarina do Visualizer’a jest w jakiś sposób niedopracowany. Trzeba zbadać, czy w obu przypadkach z API dostaję przy tej samej piosence takie same tablice z bajtami dla szybkiej transformaty Fouriera…  Ustaliłem że dwa pozostałe światła dostają ciągle współczynnik 1, więc nic dziwnego, że nie mrugają.

#10 Rozmiar i linkowanie

Oryginalna aplikacja w Javie zajmuje na telefonie 15,42 MB. Jej nie w pełni funkcjonalny odpowiednik w Xamarin… 30,97 MB, czyli póki co dwa razy tyle. Linkowanie w Xamarin ma za zadanie wyrzucać nieużywany kod. Domyślna opcja to brak, druga to linkowanie tylko dla SDK, ostatnia - najmniej bezpieczna - linkowanie dla SDK i aplikacji. Po zastosowaniu stosunkowo bezpiecznej opcji drugiej aplikacja na telefonie zajęła… 30,82 MB. Czyli praktycznie niewiele to wniosło. Opcji trzeciej póki co nie próbowałem, bo wymaga pewnych zabezpieczeń w kodzie aplikacji, by nie zostało z niego wycięte za dużo… Przydałby się jakiś dobry przewodnik do tego, informacje w podstawowej dokumentacji wskazują raczej na żmudną pracę z możliwością wpadki.

To tyle na dziś. Punkt #9 wymaga rozwiązania. Jak ktoś to czyta i ma jakiś pomysł względem tej zagadki, zapraszam do dyskusji. Poza tym w najbliższym kroku będę portował resztę kodu z Javy, by dorównać funkcjonalnością oryginałowi.

Brak komentarzy: