itmo_conspects

Лекция 3. Работа с сетью и UI

Сетевое взаимодействие

Сейчас самым распространенным сетевым стеком является TCP/IP. Он состоит из:

Подробнее о нем описано в курсе “Телекоммуникационные системы и технологии”

Чтобы Android-приложение могло работать с сетью, необходимо в AndroidManifest.xml прописать нужные разрешения:

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

Для доступа к сетевому API все чаще используется HTTP (HyperText Transfer Protocol). Обычно HTTP работает так:

Подробнее про HTTP описано в курсе “Web-разработка: Backend”

HTTP поддерживает типы запросов, которые семантически обозначают, что надо сделать с ресурсом, самые используемые это:

По умолчанию, протокол HTTP не поддерживает шифрование запросов, и любой участник передачи сообщения может его прочитать. Для борьбы с этим был создан протокол HTTPS (HTTP Secure)

Чаще всего ответом сервера на запрос будут данные в формате JSON (JavaScript Object Notation). Чтобы перевести JSON-текст в объектную модель, в Android-экосистеме используют:

Рассмотрим библиотеку GSON. Ее возможности включают автоматически маппинг JSON-объекта в сущность домена в виде POJO (Plain Old Java Object):

data class Person(
    @SerializedName("full_name")
    val name: String,
    val age: Int,

    @SerializedName(value = "email_address", alternate = ["email", "e"])
    val email: String? = null
)

val gson = Gson()

// Сериализация
val p = Person("Alice", 30)
val json: String = gson.toJson(p)
println(json) // {"name":"Alice","age":30}

// Десериализация
val jsonString = """{"name":"Bob","age":25,"email":"bob@example.com"}"""
val person: Person = gson.fromJson(jsonString, Person::class.java)
println(person) // Person(name="Bob", age=25, email="bob@example.com")

GSON позволяет задать имя ключа при сериализации и дополнительные имени для десериализации (в примере выше электронную почту можно указать как e и email). Также GSON производит автоматические преобразование к нужному типу, например, 0 и false, выданные сервером, преобразуются к булевому типу

Помимо обычного конвертера Gson(), можно указать дополнительные параметры в GsonBuilder(), например:

val gson = GsonBuilder()
   .setDateFormat("секунды ss, минуты mm, день dd, месяц MM, год yyyy")
   .setFieldNamingPolicy(FieldNamingPolicy.UPPER_CAMEL_CASE)
   .create()

Здесь указаны формат даты и политика полей имен (в данном случае свойство firstName сконвертируется в FirstName в JSON)


Одной из популярных библиотек для HTTP-обмена в Android является Retrofit. Это библиотека позволяется превратить API веб-приложений в интерфейс языка Java

Работает она так: сначала описываем сущности домены, которые приходят из API

data class Post(
    val userId: Int,
    val id: Int,
    val title: String,
    val body: String
)

Далее создаем интерфейс, аналогичный API:

interface JsonPlaceholderApi {
    // на GET запрос по http://host.com/posts
    // вернуть список постов
    @GET("posts")
    suspend fun getPosts(): List<Post>
}

Далее создает сервис и оборачивающая его функция:

val service = retrofit.create(JsonPlaceholderApi::class.java)

suspend fun fetchData() {
    try {
        val posts = service.getPosts()
        posts.forEach { println(it.title) }
    } catch (e: Exception) {
        println("Ошибка: ${e.message}")
    }
}

Также этот сервис можно настраивать под свои нужды, например, можно заказать конвертер из библиотеки GSON:

val service = retrofit.Builder()
    .baseUrl("http://example.com")
    .addConverterFactory(GsonConverterFactory.create())
    .build()
    .create(PokeApiService::class.java)

Далее этот сервис работает так:

Здесь в интерфейсе и функции fetchData есть ключевое слово suspend. Оно говорит о том, что метод и функции являются корутинами, то есть объектами асинхронного исполнения

До этого вместо корутин использовали интерфейс Call<T>:

interface MyApiService {
    @GET("users")
    fun getUsers(): Call<List<User>> 
}

Возвращенный тип представлял собой обертку над исполнением запроса, которое можно было запустить:

val call: Call<List<User>> = service.getUsers() // запрос создан, но не отправлен

call.enqueue(object : Callback<List<User>> {
    override fun onResponse(call: Call<List<User>>, response: Response<List<User>>) {
        if (response.isSuccessful) {
            val users = response.body()
            println("Получено пользователей: ${users?.size}")
        } else {
            println("Ошибка сервера: ${response.code()}")
        }
    }

    override fun onFailure(call: Call<List<User>>, t: Throwable) {
        println("Ошибка сети: ${t.message}")
    }
})

Также библиотека OkHttp позволяет работать с протоколом WebSocket:

val ws = OkHttpClient().newWebSocket(
    Request.Builder().url("ws:sample").build(), 
    object: WebSocketListener() {
        override fun onMessage(webSocket: WebSocket, text: String) {
            super.onMessage(webSocket, text)
        }

        override fun onOpen(webSocket: WebSocket, response: Response) {
            super.onOpen(webSocket, response)
        }

        override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
            super.onClosed(webSocket, code, reason)
        }

        override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
            super.onClosing(webSocket, code, reason)
        }
    }
)
ws.send("Hello")

Здесь в методе создания веб-сокета передается объект класса, реализующий 4 метода


Помимо HTTP-запросов можно отправлять сырые запросы по TCP или UDP

Так работает передача по TCP:

// Создаем TCP-сокет
val echoSocket = Socket(hostName, portNumber)

// Достаем из него поток для отправки текста
val out = PrintWriter(echoSocket.getOutputStream(), true)

// Перенаправляем стандартный поток ввода в наш сокет
System.setIn(echoSocket.getInputStream())

Так создаются UDP-сокеты:

// Создание сокета
val socket = DatagramSocket(1024, InetAddress.getByName("0.0.0.0"))
socket.reuseAddress = true
socket.broadcast = true

// Ждем датаграмму
val recvBuf = ByteArray(10)
val packet = DatagramPacket(recvBuf, recvBuf.size)
socket.receive(packet)

UI

Android позволяет создавать интерфейсы несколькими способами:

Объявление в XML-файле и программные можно комбинировать

Рассмотрим объявления в формате XML. Все XML-файлы хранятся в папке /res/, в частности:

В /res/layout как раз-таки описано то, как выглядит интерфейс приложения, а именно его элементы на соответствующих экранах, как они расположены, каких цветов и так далее

Обычно один такой файл выглядит так:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/root"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:gravity="center"
    android:padding="24dp">

    <TextView
        android:id="@+id/textCount"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Count: 0"
        android:textSize="32sp"
        android:layout_marginBottom="16dp" />

    <Button
        android:id="@+id/buttonIncrement"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Increment" />

    <Space
        android:layout_width="wrap_content"
        android:layout_height="12dp" />

    <Button
        android:id="@+id/buttonReset"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Reset" />

</LinearLayout>

Здесь интерфейс описан как линейный макет с объектами представления

Иерархия в интерфейсе

Объект представления View

Класс View - это базовый класс, который представляет элемент интерфейса

View является классом-родителем для множества других элементов:

В XML-файле объект представления задается тегом его класса, например:

    <TextView
        android:id="@+id/textCount"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Count: 0"
        android:textSize="32sp"
        android:layout_marginBottom="16dp" />

Далее идут атрибуты объекта представления, такие как:

Жизненный цикл View состоит из следующих этапов:

Если View некорректна или неактуальна, можно воспользоваться следующими методами:

Жизненный цикл представления


Также можно создать свой наследник View. Для этого создаем класс:

class CircleView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null
) : View(context, attrs) {

    private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        color = Color.RED
    }

    fun setColor(color: Int) {
        paint.color = color
        invalidate()  // перерисовать
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        // Рисуем круг по центру View
        val cx = width / 2f
        val cy = height / 2f
        val radius = min(width, height) / 2f
        canvas.drawCircle(cx, cy, radius, paint)
    }
}

Далее использовать программно или в XML:

<com.example.exampleapp.CircleView
    android:layout_width="100dp"
    android:layout_height="100dp" />

Для таких View можно объявлять свои атрибуты в файле /res/values/attrs.xml, например:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="CircleView">
        <attr name="circleColor" format="color" />
    </declare-styleable>
</resources>

Далее в классе читать атрибуты:

class CircleView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {

    init {
        // Читаем атрибуты
        val typedArray = context.obtainStyledAttributes(attrs, R.styleable.CircleView)
        val defaultColor = Color.RED
        val color = typedArray.getColor(R.styleable.CircleView_circleColor, defaultColor)
        typedArray.recycle()

        paint.color = color
    }

    // ...
}

Здесь defStyleAttr указывает на атрибут в теме, содержащий стиль по уполчанию для CircleView

Далее цвет можно указать так:

    <com.example.exampleapp.CircleView
        android:layout_width="100dp"
        android:layout_height="100dp"
        app:circleColor="@color/blue" />

Объект макета Layout

Так в Android-приложении весь интерфейс одного экрана живет в одной активности. Для компоновки элементов интерфейса используют объект макета Layout. Сам макет с его элементами описывается в XML-файле в папке /res/layout (или программно)

Рассмотрим основные классы, которые наследуются от Layout:

Самый быстрый - это FrameLayout. Далее идут LinearLayout, GridLayout и другие

Разметка элементов регулируется атрибутами в объектах макетов и дочерних элементах

Макеты

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

Также для удобства макеты можно вкладывать друг в друга, указывая имя XML-файла с помощью тега <include>. Например, если есть res/layout/header.xml:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="#2196F3"
    android:padding="16dp">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Заголовок приложения"
        android:textColor="#FFFFFF" />
</LinearLayout>

То можно его включить в другом макете так:

<include
    android:id="@+id/main_header"
    layout="@layout/header" />

А если нужно не весь Layout, а только его содержимое, то можно применить тег <merge>:

<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Заголовок приложения"
        android:textColor="#FFFFFF" />
</merge>
<include layout="@layout/header" />

Это позволяет улучшить производительность и уменьшить вложенность иерархии


Программно создание макета выглядит так:

public class MainActivity extends Activity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        // Создаем LinearLayout
        LinearLayout layout = new LinearLayout(this);
        layout.setOrientation(LinearLayout.VERTICAL);
        layout.setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT));

        // Создаем кнопку
        Button button = new Button(this);
        button.setText("Нажми меня");
        button.setLayoutParams(new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT));

        // Добавляем кнопку в LinearLayout
        layout.addView(button);

        // Устанавливаем LinearLayout как корневой элемент Activity
        setContentView(layout);
    }
}

Процесс надувания

Далее, когда запускается приложение и создается активность, происходит процесс надувания (inflate) - специальный сервис LayoutInflater превращает XML-теги в объекты языка, с которыми можно работать внутри кода

// Получаем экземпляр LayoutInflater
val inflater = LayoutInflater.from(context) 
// или getLayoutInflater()

// Надувание
val rootView: View = inflater.inflate(R.layout.item_view, null)

// Теперь находим дочерние представления по их идентификатору,
// используя findViewById<T>
val title = rootView.findViewById<TextView>(R.id.titleTextView)
val button = rootView.findViewById<Button>(R.id.actionButton)

// Устанавливаем текст и обработчик
title.text = "Заголовок"
button.setOnClickListener {
    // действие
}

// Добавляем rootView на какой-нибудь макет
someLinearLayout.addView(rootView)

Здесь R в R.layout.item_view, R.id.titleTextView, R.id.actionButton - это автоматически сгенерированный класс, создающийся на этапе компиляции и состоящий из указателей на ресурсы приложений (в данном случае мы заказали кнопку из файла или /res/layout/item_button.xml)

Метод inflate имеет такую сигнатуру:

inflate(@LayoutRes resource: Int, root: ViewGroup?, attachToRoot: Boolean): View

Здесь resource - указатель из R, root - родительский элемент, который будет использоваться для вычисления размеров и отступов, attachToRoot - надо ли сразу добавить надутый элемент в родителю

Внутри LayoutInflater:

Объекты связки

Android SDK позволяет использовать объекты связки (binding)

Сначала появился объект Synthetic Binding - плагин kotlin-android-extensions позволял обращаться к View по идентификатору напрямую, без findViewById, как к свойствам

Плагин подключался в build.gradle:

plugins {
    id 'kotlin-android-extensions'
}

В коде импортировался kotlinx.android.synthetic.main.<layout>.* и класс использовался:

import kotlinx.android.synthetic.main.activity_main.*
textView.text = "Hello, world"

Плагин автоматически генерировал кэш в хешмапе, избегая повторного вызова методов findViewById, но мог вернуть null, если объект представления не существует

Также была проблема с конфликтами имен, поэтому от такого решения отказались


Далее появился объект связки представления - View Binding

Для его использования нужно добавить флаг в build.gradle.kts:

android {
    buildFeatures {
        viewBinding = true
    }
}

Далее объект связки представляет свойство в объекте активности или фрагмента, которое можно надуть:

class MainActivity : AppCompatActivity() {
    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        // непосредственное изменение
        binding.button.text = "Нажми"
    }
}

ViewBinding позволяет обращаться к внутренним элементами интерфейса, описанных в XML, не через метод findViewById, а через свойства объекта автоматически сгенерированного класса ___Binding

При этом поля имеют названия указанных идентификаторов, но в camelCase (то есть @+id/button_ok преобразовывается в buttonOk), работает быстрее, чем findViewById, и более типобезопасно


Для реактивного интерфейса придумали объект связки данных Data Binding

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

Data Binding позволяет связывать компоненты интерфейса напрямую с источниками данных прямо в XML. Включается Data Binding в build.gradle.kts:

android {
    buildFeatures {
        dataBinding = true
    }
}

Далее в XML-файле добавляется тег <data>, например:

<layout xmlns:android="http://schemas.android.com/apk/res/android">
    <data>
        <variable name="viewModel" type="com.example.MyViewModel" />
    </data>
    <LinearLayout>
        <TextView android:text="@{viewModel.userName}" />
        <Button android:onClick="@{() -> viewModel.onButtonClick()}" />
        <EditText android:text="@={viewModel.userInput}" />
    </LinearLayout>
</layout>

В теге <data> объявляется переменная, значения свойств которой можно записать в объектах представления

Программно viewModel привязывается к объектам интерфейса так:

val binding: ActivityMainBinding = DataBindingUtil.setContentView(this, R.layout.activity_main)
binding.viewModel = myViewModel
binding.lifecycleOwner = this   // для наблюдения

Для реактивного подхода используется классы, подобные LiveData, за которыми можно закрепить функцию, исполняющуюся при изменении данных (паттерн “Наблюдатель”)

Data Binding позволяет уменьшить количество кода в активности, поддерживает двухстороннее изменение (если есть поле для ввода, то значение поменяется и в коде), но приложение медленнее компилируется, и его сложнее отлаживать

Фреймворк Jetpack Compose

Jetpack Compose - новый фреймворк, который позволяет декларативно задать интерфейс на языке программирования

Пример его использования:

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            CounterAppTheme {
                Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
                    CounterScreen()
                }
            }
        }
    }
}

@Composable
fun CounterScreen() {
    var count by remember { mutableStateOf(0) }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(24.dp),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(text = "Count: $count", fontSize = 32.sp, modifier = Modifier.padding(bottom = 16.dp))
        Button(onClick = { count++ }) {
            Text("Increment")
        }
        Spacer(Modifier.height(12.dp))
        OutlinedButton(onClick = { count = 0 }) {
            Text("Reset")
        }
    }
}

Здесь задается столбец из элементов: текста с счетчиком, кнопки инкремента и кнопки сброса

Адаптивный дизайн

Далее будут приведены хорошие практики для создания дизайна, который будет хорошо работать на разных устройствах: