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.
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.