Разрабатывая под Android, всегда нужно быть начеку. Шаг влево / шаг вправо — и вот прошел ещё один час за дебагом. Кюветы могут быть какие угодно: начиная от обычных багов в SDK и заканчивая неочевидными именами методов с контекстно зависимым результатом (да-да, Fragment.getFragmentManager(), это я о тебе).
Иногда так случается, что обычный способ создания объекта с кучей не подходит:
Такой ситуацией может быть, например, landscape отображение формы. Хотелось бы в таком случае иметь нечто вроде такого:
Как сделать такое же выравнивание 50 на 50? Существует несколько основных подходов:
Однако они все имеют свои недостатки:
Более того, довольно часто необходимо использовать магию числа 0dp & weight=1, чтобы добиться гибкого дизайна. Ни TableLayout, ни RelativeLayout тут вам не помогут. При первой же попытке использовать что-то вроде TextView.setEllipsize(), начнутся проблемы и боль.
И тут вы наверное подметили, что я пропустил ещё один элемент. Казалось бы, на помощь приходит GridLayout, но и тот оказывается бесполезен из-за того, что не поддерживает свойство layout_weight. Так что же делать?
До некоторых пор делать было действительно нечего — либо мучайся с RelativeLayout, либо применяй LinearLayout , либо заполняй всё программный путем (для особо извращенных).
Однако с 21 версии GridLayout наконец-то начал поддерживать свойство layout_weight и, что самое важное, это изменение было добавлено в AppCompat в виде android.support.v7.widget.GridLayout!
К моменту когда я узнал об этом (и вообще о том, что обычный GridLayout чхать хотел на мой weight), я потратил по меньшей мере неделю, пытаясь понять почему мой layout поплыл вправо (как здесь). Пожалуй, это одно из самых важных нововведений, которое, почему-то, осталось без должного внимания. К счастью, ответы на stackoverflow (1, 2) уже начинают дописывать.
Также советую заглянуть на страничку к новым PercentRelativeLayout и PercentFrameLayout — это действительно бомба. Название говорит само за себя и позволяет сделать крайне адаптивный дизайн. iOS'ники оценят. И ах да, оно есть в AppCompat.
Как-то раз захотел я написать свой PresenterManager в виде синглтона (привет от MVP). Чтобы вовремя удалять Presenter'ов, я использовал Activity.isFinishing(), собирая id Presenter'ов фрагментов в активити и удаляя их вместе с ним. Естественно, такой способ плохо работал в случае с NavigationView — фрагменты менялись через FragmentTransaction.replace(), Presenter'ы копились и всё шло коту под хвост.
Погуглив смальца, был найден метод Fragment.isRemoving(), который вроде бы делает то же самое, но для фрагментов. Я переписал код PresenterManager'а и был доволен. Конец…
… наступил моей спокойной жизни, когда я пытался заставить это работать. Честно, я пытался и так, и эдак, но поведение этого метода вкорне отличается от Activity.isFinishing(). Гугл был неправ. Если у вас когда-нибудь возникнет подобная задача, подумайте трижды прежде чем использовать Fragment.isRemoving(). Я серьезно. Особенно уделите внимание логам при повороте экрана.
Обычная задача при реализации пагинации — необходимо отображать ProgressBar на время загрузки новых данных.
В отличие от ListView, RecyclerView обладает куда большими возможностями — чего только стоит RecyclerView.Adapter.notifyItemRangeInserted() по сравнению с той самой головной болью ListView.
Однако попробовав использовать его в проекте вместо ListView, сразу же натыкаешься на множество нюансов: где свойство ListView.setDivider()? Где нечто вроде ListView.addHeaderView()? Что ещё за RecyclerView.Adapter.getItemViewType() и т.д., и т.п.
Разобраться то со всей этой свалкой новой информации несложно, однако кое-что неприятное всё равно остается. Добавление Divider/Header заствляет писать тучи кода. Что уж и говорить о сложных layout'ах? Довеча доводилось делать RecyclerView с 4-мя различными Header'ами и Footer'ом с контроллами. Скажем так, опечаленным и удрученным я ходил очень долго.
На самом деле всё не так плохо, если знать, что искать. Самая основная проблема RecyclerView (и оно же его основное преимущество) — с ним можно делать всё, что угодно. Нет практически никаких рамок. Отсюда и вытекает проблема: хочешь Header — сделай сам. Но к счастью, «сделай сам» уже сделали за нас другие, так что давайте пользоваться.
Типичные проблемы и их решения:
Хотя для меня всё равно остается загадкой — почему нельзя было сделать какие-нибудь SimpleDivider / SimpleHeaderAdapter и т.д. сразу в SDK?
Нестолько проблема, сколько недостаток документации. Вот что там написано:
И тут люди делятся на два типа. Первые: всё ж ясно. Вторые: чо это вообще значит то?
Проблема в том, что даже если вы отнесли себя к первым людям, вас может поставить в тупик вопрос: а зачем этот метод? Да-да, чтобы вернуть уникальный ID! Я знаю. Но зачем оно надо? И нет, ответ «гугл пишет, что так быстрее скроллиться будет!» меня не устроит.
Ускорение от RecyclerView.Adapter.setHasStableIds() действительно можно получить, но только в одном случае — если вы повсеместно используете RecyclerView.Adapter.notifyDataSetChanged() (а тут они соизволили написать, зачем нужны stable id). Если вы имеете статичные данные, то вам этот метод не даст ровным счетом ничего, а возможно даже и немного замедлит из-за внутренних проверок ID. Узнал я об этом только после чтения исходников, а чуть позже случайно наткнулся на эту статью.
Задача — получить html-текст от сервера и вывести его на экран. Текст сервером отдается в виде "& lt;html& gt;". Всё. Это вся задача. Сложно? Существует же WebView, который может отобразить html в пару строк. Да что там, даже TextView может это сделать! Раз-два и готово… да?.. нет?.. ну должно же?!
К сожалению, тут всё не так гладко:
(Ситуация специфична и напрямую к проблемам Android'а не относится, но в качестве затравки перед следующим кюветом решил добавить)
В ответ на один из api-запросов приходит битовая маска прав доступа в виде int'а. Нужно обрабатывать элементы этой маски.
Первое, что приходит в голову — int'овые константы и битовые операциями для проверок. Несомненно, оно всегда работает. Но что если хочется большего? Как насчет EnumSet?
«Без проблем» — ответит проггер-бородач и разобьет архитектуру моделей ещё на несколько уровней: POJO, Model, Entity, UiModel и чем ещё чёрт не шутит. Но если лень и хочется без доп. классов? Что тогда?
Создаём нужный нам enum, позаботившись о «битовости» имён в @SerializedName:
Определяем JsonDeserializer для десериализации из json в EnumSet:
И добавляем его в Gson:
В результате:
Начнём с настройки. Gson формируется также, как и ранее. Retrofit создаётся вот так:
А данные выглядят так:
Благодаря возможности Gson к прямому парсингу enum через обычный @SerializedName, стало возможным избавиться от необходимости создавать всякие дополнительные классы-прослойки. Все данные будут сразу идти из запросов в Model. Всё прекрасно:
А теперь, уважаемые знатоки, внимание вопрос! Что пошло не так и почему оно не работает?
Я специально опустил информацию о том, что именно здесь не работает. Дело в том, что если посмотреть в логи, то запрос monthApi.getFirstMonth(season) будет обработан, как index.php?page[api]=years&season_lookup=AUTUMN… «ээээ, что за дела?» — скажу я. А каков ваш ответ? Почему такой результат? Ещё не догадались? Тогда вы попали.
Когда я столкнулся с этой задачей, мне потребовалось несколько часов поисков в исходниках, чтобы понять одну вещь (или скорее даже вспомнить): да не используется Gson при отправке @GET / @POST и других подобных _запросов_ вообще! Ведь действительно, когда вы последний раз видели нечто вроде index.php?page[api]=years&season_lookup={a:123; b:321}? Это не имеет смысла. Retrofit 2 использует Gson только при конвертации Body, но никак не для самих запросов. В итоге? используется просто season.toString() — отсюда и результат.
Однако, если уж ооочень хочется(а я из таких) использовать enum с конвертацией через Gson в запросе, то вам сюда — ещё один конвертор, всё как всегда.
И напоследок, хотелось бы сказать одну вещь тем, кто пишет так:
Начните уже использовать Interceptor'оры! Я понимаю, что Retrofit использовать очень просто и поэтому никто не читает документацию, но когда 3 часа сидишь и вычищаешь код не только от auth-token, но и ото всяких специфических current_location, battery_level, busy_status — настигает великая печалька (не спрашивайте, зачем передавать battery_level в каждый запрос. Сам в шоке). Почитать об этом можно тут.
Что ж, на этот раз вышло куда больше текста, чем я планировал. Некоторые менее интересные кюветы пришлось выкинуть, другие же я решил оставил для следующего раза.
Вопреки посылу предыдущей части, в этот раз я старался заставить вас не «гуглить в первую очередь», а прежде всего подумать «а зачем я это делаю?». Иногда проблему создает не SDK или библиотека, а сам программист и, к сожалению, в этом случае всё куда плачевнее. Не стоит недооценивать выбранный инструментарий, как и не стоит переоценивать его.
В общем, если вам нравится андроид и/или вы планируете им заняться — всегда держите себя в курсе мировых трендов. Ну или поищите здесь более удобный для себя новостной ресурс. Там же вы можете найти много информации об Android SDK, популярных библиотеках и т.д., и т.п.
В предыдущей статье были описаны кюветы «на поверхности» SDK, в которые угодить очень легко. На этот же раз кюветы будут поглубже, помудрёнее и поспецифичнее. Также будет несколько моментов, связанных с Retrofit 2 & Gson.
1. GridLayout не реагирует на layout_weight
Ситуация
Иногда так случается, что обычный способ создания объекта с кучей не подходит:
Обычная форма
Такой ситуацией может быть, например, landscape отображение формы. Хотелось бы в таком случае иметь нечто вроде такого:
Форма для landscape
Как сделать такое же выравнивание 50 на 50? Существует несколько основных подходов:
- Вложенные LinearLayout
- RelativeLayout
- TableLayout или GridLayout
Однако они все имеют свои недостатки:
- Обилие LinearLayout приводит к монструозности xml'ки
, а она приводит к смерте котиков. - RelativeLayout усложняет изменение в будущем (поменять местами несколько строк в форме или добавить разделитель будет той ещё задачкой. Про View.setVisibility(View.GONE) я и вовсе молчу).
- Ну а TableLayout вообще никто не использует… или используют, но редко. Я таких людей не знаю.
Более того, довольно часто необходимо использовать магию числа 0dp & weight=1, чтобы добиться гибкого дизайна. Ни TableLayout, ни RelativeLayout тут вам не помогут. При первой же попытке использовать что-то вроде TextView.setEllipsize(), начнутся проблемы и боль.
И тут вы наверное подметили, что я пропустил ещё один элемент. Казалось бы, на помощь приходит GridLayout, но и тот оказывается бесполезен из-за того, что не поддерживает свойство layout_weight. Так что же делать?
Решение
До некоторых пор делать было действительно нечего — либо мучайся с RelativeLayout, либо применяй LinearLayout , либо заполняй всё программный путем (для особо извращенных).
Однако с 21 версии GridLayout наконец-то начал поддерживать свойство layout_weight и, что самое важное, это изменение было добавлено в AppCompat в виде android.support.v7.widget.GridLayout!
К моменту когда я узнал об этом (и вообще о том, что обычный GridLayout чхать хотел на мой weight), я потратил по меньшей мере неделю, пытаясь понять почему мой layout поплыл вправо (как здесь). Пожалуй, это одно из самых важных нововведений, которое, почему-то, осталось без должного внимания. К счастью, ответы на stackoverflow (1, 2) уже начинают дописывать.
Также советую заглянуть на страничку к новым PercentRelativeLayout и PercentFrameLayout — это действительно бомба. Название говорит само за себя и позволяет сделать крайне адаптивный дизайн. iOS'ники оценят. И ах да, оно есть в AppCompat.
2. Fragment.isRemoving() и Acitivity.isFinishing() равны?
Ситуация
Как-то раз захотел я написать свой PresenterManager в виде синглтона (привет от MVP). Чтобы вовремя удалять Presenter'ов, я использовал Activity.isFinishing(), собирая id Presenter'ов фрагментов в активити и удаляя их вместе с ним. Естественно, такой способ плохо работал в случае с NavigationView — фрагменты менялись через FragmentTransaction.replace(), Presenter'ы копились и всё шло коту под хвост.
Погуглив смальца, был найден метод Fragment.isRemoving(), который вроде бы делает то же самое, но для фрагментов. Я переписал код PresenterManager'а и был доволен. Конец…
Решение
… наступил моей спокойной жизни, когда я пытался заставить это работать. Честно, я пытался и так, и эдак, но поведение этого метода вкорне отличается от Activity.isFinishing(). Гугл был неправ. Если у вас когда-нибудь возникнет подобная задача, подумайте трижды прежде чем использовать Fragment.isRemoving(). Я серьезно. Особенно уделите внимание логам при повороте экрана.
Кстати с Acitivity.isFinishing() тоже не всё так гладко: сверните приложение с >1 активити в стэке, дождитесь ситуации нехватки памяти, вернитесь обратно и воспользуйтесь Up Navigation и *вуаля*!.. Это был простой рецепт того, как поиметь Activity.isFinishing() == false для активити, которые вы больше никогда не увидите.
3. Header/Footer в RecyclerView
Ситуация
Обычная задача при реализации пагинации — необходимо отображать ProgressBar на время загрузки новых данных.
В отличие от ListView, RecyclerView обладает куда большими возможностями — чего только стоит RecyclerView.Adapter.notifyItemRangeInserted() по сравнению с той самой головной болью ListView.
Однако попробовав использовать его в проекте вместо ListView, сразу же натыкаешься на множество нюансов: где свойство ListView.setDivider()? Где нечто вроде ListView.addHeaderView()? Что ещё за RecyclerView.Adapter.getItemViewType() и т.д., и т.п.
Разобраться то со всей этой свалкой новой информации несложно, однако кое-что неприятное всё равно остается. Добавление Divider/Header заствляет писать тучи кода. Что уж и говорить о сложных layout'ах? Довеча доводилось делать RecyclerView с 4-мя различными Header'ами и Footer'ом с контроллами. Скажем так, опечаленным и удрученным я ходил очень долго.
Решение
На самом деле всё не так плохо, если знать, что искать. Самая основная проблема RecyclerView (и оно же его основное преимущество) — с ним можно делать всё, что угодно. Нет практически никаких рамок. Отсюда и вытекает проблема: хочешь Header — сделай сам. Но к счастью, «сделай сам» уже сделали за нас другие, так что давайте пользоваться.
Типичные проблемы и их решения:
- Заголовки для групп элементов (например, в словаре «А» будет являться заголовком для всех слов, начинающихся с этой буквы) — проще всего сделать через единственный item-layout, не добавляя 2-ой ненужный тип ViewHolder'а. Добавьте проверку на то, что текущий элемент ознаменует переход от одной буквы к другой и включите спрятанный в layout заголовок через View.VISIBLE.
- Простой divider — копи-паст этого кода в проект. Никаких лишних махинаций. Работает через RecyclerView.addItemDecoration()
- Добавлените Header / Footer / Drag&Drop и т.д. — если делать ручками, то либо заводить новый тип на каждый новый ViewHolder (не советую), либо делать WrapperAdapter (куда приятнее). Но ещё лучше посмотреть тут и выбрать понравившуюся либу. Лично мне нравятся сразу две: FastAdapter и UltimateRecyclerView
- Нужна пагинация, но лень возиться с Header / Footer для ProgressBar'ов — библиотека Paginate от одного из разработчиков твиттера.
Хотя для меня всё равно остается загадкой — почему нельзя было сделать какие-нибудь SimpleDivider / SimpleHeaderAdapter и т.д. сразу в SDK?
4. Ускорение с RecyclerView.Adapter.setHasStableIds()
Что с ним не так?
Нестолько проблема, сколько недостаток документации. Вот что там написано:
Returns true if this adapter publishes a unique long value that can act as a key for the item at a given position in the data set. If that item is relocated in the data set, the ID returned for that item should be the same.
И тут люди делятся на два типа. Первые: всё ж ясно. Вторые: чо это вообще значит то?
Проблема в том, что даже если вы отнесли себя к первым людям, вас может поставить в тупик вопрос: а зачем этот метод? Да-да, чтобы вернуть уникальный ID! Я знаю. Но зачем оно надо? И нет, ответ «гугл пишет, что так быстрее скроллиться будет!» меня не устроит.
А вот в чём дело
Ускорение от RecyclerView.Adapter.setHasStableIds() действительно можно получить, но только в одном случае — если вы повсеместно используете RecyclerView.Adapter.notifyDataSetChanged() (а тут они соизволили написать, зачем нужны stable id). Если вы имеете статичные данные, то вам этот метод не даст ровным счетом ничего, а возможно даже и немного замедлит из-за внутренних проверок ID. Узнал я об этом только после чтения исходников, а чуть позже случайно наткнулся на эту статью.
5. WebView
Ситуация
Задача — получить html-текст от сервера и вывести его на экран. Текст сервером отдается в виде "& lt;html& gt;". Всё. Это вся задача. Сложно? Существует же WebView, который может отобразить html в пару строк. Да что там, даже TextView может это сделать! Раз-два и готово… да?.. нет?.. ну должно же?!
Решение
К сожалению, тут всё не так гладко:
- Начнём с того, что нет метода типа HtmlUtils.unescape() в Android SDK. Если хочешь "& lt;" превратить в "<", то самый простой способ (кроме прописывания regex'а ручками) — подключить apache с его StringUtils.unescapeHtml4().
- Следующей проблемой будут артефакты при прокрутке. Совершенно внезапно (да, Android SDK?), WebView будет мигать черным цветом. Что делать — рассказывается тут и тут. Лично мне помогла только комбинация этих подходов.
- И если вас ещё не удивило обилие проблем от столь простой задачи, то вот добивалочка: нужно отобразить ProgressBar, пока html-страничка не отрендерилась. И тут всё плохо. То есть реально плохо. Все представленые на stackoverflow решения работают через раз или не работаю вовсе (тык, тык). Единственный работающий доселе способ был с применением WebView.setPictureListener (), однако тот теперь объявлен deprecated и тут уже ничего не попишешь.
В итоге, единственное, что можно посоветовать — отказаться от ProgressBar'а. Либо, если уж совсем-совсем-совсем приспичит — добавить его прямо в html-код, проверяя через javascript готовность страницы.Но это уже для клуба элитных мазахистов.
6. Gson: битовая маска в виде EnumSet
Когда/Где/Зачем?
(Ситуация специфична и напрямую к проблемам Android'а не относится, но в качестве затравки перед следующим кюветом решил добавить)
В ответ на один из api-запросов приходит битовая маска прав доступа в виде int'а. Нужно обрабатывать элементы этой маски.
Первое, что приходит в голову — int'овые константы и битовые операциями для проверок. Несомненно, оно всегда работает. Но что если хочется большего? Как насчет EnumSet?
«Без проблем» — ответит проггер-бородач и разобьет архитектуру моделей ещё на несколько уровней: POJO, Model, Entity, UiModel и чем ещё чёрт не шутит. Но если лень и хочется без доп. классов? Что тогда?
Решение
Создаём нужный нам enum, позаботившись о «битовости» имён в @SerializedName:
enum Access
public enum Access {
@SerializedName("1")
CREATE,
@SerializedName("2")
READ;
@SerializedName("4")
UPDATE;
@SerializedName("8")
DELETE;
}
Определяем JsonDeserializer для десериализации из json в EnumSet:
EnumMaskConverter
public class EnumMaskConverter<E extends Enum<E>> implements JsonDeserializer<EnumSet<E>> {
Class<E> enumClass;
public EnumMaskConverter(Class<E> enumClass) {
this.enumClass = enumClass;
}
@Override
public EnumSet<E> deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
long mask = json.getAsLong();
EnumSet<E> set = EnumSet.noneOf(enumClass);
for (E bit : enumClass.getEnumConstants()) {
final String value = EnumUtils.GetSerializedNameValue(bit);
assert value != null;
long key = Integer.valueOf(value);
if ((mask & key) != 0) {
set.add(bit);
}
}
return set;
}
}
И добавляем его в Gson:
GsonBuilder
GsonBuilder gsonBuilder = new GsonBuilder();
gsonBuilder.registerTypeAdapter((new TypeToken<EnumSet<Access>>() {}).getType(), new EnumMaskConverter<>(Access.class));
Gson gson = gsonBuilder.create();
В результате:
Использование
class MyModel {
@SerializedName("mask")
public EnumSet<Access> access;
}
/* ...some lines later... */
if (myModel.access.containsAll(EnumSet.of(Access.READ, Access.UPDATE, Access.DELETE))) {
/* do something really cool */
}
7. Retrofit: Enum в @GET запросе
Ситуация
Начнём с настройки. Gson формируется также, как и ранее. Retrofit создаётся вот так:
new Retrofit.Builder()
retrofit = new Retrofit.Builder()
.baseUrl(ApiConstants.API_ENDPOINT)
.client(httpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
.build();
А данные выглядят так:
enum Season
public enum Season {
@SerializedName("3")
AUTUMN,
@SerializedName("1")
SPRING;
}
Благодаря возможности Gson к прямому парсингу enum через обычный @SerializedName, стало возможным избавиться от необходимости создавать всякие дополнительные классы-прослойки. Все данные будут сразу идти из запросов в Model. Всё прекрасно:
Retrofit Service
public interface MonthApi {
@GET("index.php?page[api]=selectors")
Observable<MonthSelector> getPriorityMonthSelector();
@GET("index.php?page[api]=years")
Observable<Month> getFirstMonth(@Query("season") Season season);
}
Применение
class MonthSelector {
@SerializedName("season")
public Season season;
}
/* ...some mouses later... */
MonthSelector selector = monthApi.getPriorityMonthSelector();
Season season = selector.season;
/* ...some cats later... */
Month month = monthApi.getFirstMonth(season);
А теперь, уважаемые знатоки, внимание вопрос! Что пошло не так и почему оно не работает?
Решение
Я специально опустил информацию о том, что именно здесь не работает. Дело в том, что если посмотреть в логи, то запрос monthApi.getFirstMonth(season) будет обработан, как index.php?page[api]=years&season_lookup=AUTUMN… «ээээ, что за дела?» — скажу я. А каков ваш ответ? Почему такой результат? Ещё не догадались? Тогда вы попали.
Когда я столкнулся с этой задачей, мне потребовалось несколько часов поисков в исходниках, чтобы понять одну вещь (или скорее даже вспомнить): да не используется Gson при отправке @GET / @POST и других подобных _запросов_ вообще! Ведь действительно, когда вы последний раз видели нечто вроде index.php?page[api]=years&season_lookup={a:123; b:321}? Это не имеет смысла. Retrofit 2 использует Gson только при конвертации Body, но никак не для самих запросов. В итоге? используется просто season.toString() — отсюда и результат.
Однако, если уж ооочень хочется
8. Retrofit: передача auth-token
И напоследок, хотелось бы сказать одну вещь тем, кто пишет так:
Любой Retrofit Service
public interface CoolApi {
@GET("index.php?page[api]=need")
Observable<Data>
just(@Header("auth-token") String authToken);
// ^шлём auth-token
@GET("index.php?page[api]=more")
Observable<Data>
not(@Header("auth-token") String authToken);
// ^шлём auth-token ещё раз
@GET("index.php?page[api]=gold")
Observable<Data>
doIt(@Header("auth-token") String authToken);
// ^шлём auth-token в 101ый раз!
}
Начните уже использовать Interceptor'оры! Я понимаю, что Retrofit использовать очень просто и поэтому никто не читает документацию, но когда 3 часа сидишь и вычищаешь код не только от auth-token, но и ото всяких специфических current_location, battery_level, busy_status — настигает великая печалька (не спрашивайте, зачем передавать battery_level в каждый запрос. Сам в шоке). Почитать об этом можно тут.
Вместо заключения
Что ж, на этот раз вышло куда больше текста, чем я планировал. Некоторые менее интересные кюветы пришлось выкинуть, другие же я решил оставил для следующего раза.
Вопреки посылу предыдущей части, в этот раз я старался заставить вас не «гуглить в первую очередь», а прежде всего подумать «а зачем я это делаю?». Иногда проблему создает не SDK или библиотека, а сам программист и, к сожалению, в этом случае всё куда плачевнее. Не стоит недооценивать выбранный инструментарий, как и не стоит переоценивать его.
В общем, если вам нравится андроид и/или вы планируете им заняться — всегда держите себя в курсе мировых трендов. Ну или поищите здесь более удобный для себя новостной ресурс. Там же вы можете найти много информации об Android SDK, популярных библиотеках и т.д., и т.п.
Комментарии (0)