Поскольку статей по библиотеке Android Paging и особенно по пагинации в библиотеке AdapterDelegates мне показалось довольно мало- пишу свою.
Библиотека Paging позволяет организовать пагинацию данных в списках в обе стороны, а также умеет показывать loader внизу списка и некоторые другие штуки.
Гугл рекомендует использовать AsyncPagingDataDiffer, но я приведу пример с PagingDataAdapter, который представляет wrapper над AsyncPagingDataDiffer и имплементит RecyclerView.Adapter.
Не буду делать RemoteMediator- в большинстве задач он не нужен, можем потом дополню статью и им
В коде будут комментарии, что и для чего нужно. Если найдете ошибку- напишите пожалуйста на телеграм RomanKryvolapov. Пишу все в блокноте, так как лень создавать проект
Допустим, у нас есть список с категориями пользователей и пользователями
// интерфейс для удобства, или Any interface UserOrCategory // модели для domain слоя приложения data class UserData( val id: String, val title: String, val description: String, val avatar: String, ) : UserOrCategory data class CategoryData( val id: String, val title: String, val error: String ) : UserOrCategory
сетевые модели
// интерфейс для преобразования сетевой модели в модель для приложения interface DomainMapper<T : Any> { fun mapToDomainModel(): T } // сетевые модели для data слоя приложения // предположим что в случае неудачи api выдает текстовую ошибку error data class UsersAndCategoriesNetworkModel( private val list: List<Any>, private val error: String?, ) : DomainMapper<List<UserOrCategory>> { // В итоге мы должны получить list, в котором находятся пользователи и категории List<UserOrCategory> override fun mapToDomainModel() = list.map { userOrCategory -> if (userOrCategory.type == "user") { (userOrCategory as UserNetworkModel).mapToDomainModel() } else if (userOrCategory.type == "category") { (userOrCategory as CategoryNetworkModel).mapToDomainModel() } } ?: emptyList() } data class UserNetworkModel( private val id: String?, private val type: String?, private val title: String?, private val description: String?, private val avatar: String?, ) : DomainMapper<UserData> { override fun mapToDomainModel() = UserData( id = id ?: "", title = title ?: "Unknown", description = description ?: "No description", avatar = avatar ?: "", ) } data class CategoryNetworkModel( private val id: String?, private val type: String?, private val title: String?, ) : DomainMapper<CategoryData> { override fun mapToDomainModel() = CategoryData( id = id ?: "", title = title ?: "Unknown", ) }
далее нам необходимо в data слое сделать PagingSource
// getUsersAndCategories в networkApi - suspend функция, которая возвращает UsersAndCategoriesNetworkModel // например @FormUrlEncoded @POST("get_users") suspend fun getUsersAndCategories( @Field("page_number") pageNumber: Int, @Field("page_size") pageSize: Int ): Response<UsersAndCategoriesNetworkModel> // или записываем поля в мапу и передаем как body @POST("get_users") suspend fun getUsersAndCategories( @Body body: Map<String, String> ): Response<UsersAndCategoriesNetworkModel> // источник данных для data слоя приложения, по идее вызов getUsersAndCategories // можно бы было сделать через репозиторий, но тут сделал напрямую. Также источником данных теоретически может быть не только NetworkApi но все что угодно class UserAndCategoryDataSource( private val pageSize: Int, private val initialPageNumber: Int, private val networkApi: NetworkApi, ) : PagingSource<Int, TransactionHistory>() { override suspend fun load(params: LoadParams<Int>): LoadResult<Int, UserOrCategory> { // номер страницы находится в key, если он null то в класс мы также передаем начальный номер страницы // initialPageNumber для api, так как он может быть например 0 или 1 val pageNumber: Int = params.key ?: initialPageNumber return try { val response = networkApi.getUsersAndCategories(pageNumber, pageSize) // если текст не пустой, то пришла ошибка if (!response?.body()?.error.isNullOrEmpty()) { LoadResult.Error(throwable = java.lang.Exception()) } else { val userAndCategoryList = response.body()?.mapToDomainModel() // что будет в pageNumber для следующей страницы val nextPageNumber: Int? = if (!userAndCategoryList.isNullOrEmpty()) pageNumber + 1 else null // и для предыдущей val prevPageNumber: Int? = if (pageNumber > initialPageNumber) pageNumber - 1 else null // а здесь данные для пейджера LoadResult.Page( data = userAndCategoryList.orEmpty(), prevKey = prevPageNumber, nextKey = nextPageNumber ) } // здесь бы лучше указать сетевую ошибку а не Exception } catch (e: Exception) { LoadResult.Error(throwable = e) } } override fun getRefreshKey(state: PagingState<Int, UserOrCategory>): Int? { val anchorPosition = state.anchorPosition ?: return null val page = state.closestPageToPosition(anchorPosition) ?: return null return page.prevKey?.plus(1) ?: page.nextKey?.minus(1) }
далее приступаем к ViewModel
// networkApi будем инжектить class UserAndCategoryViewModel(private val networkApi: NetworkApi) : ViewModel() { fun getUsersAndCategories( pageSize: Int, prefetchDistance: Int, initialLoadSize: Int, initialPageNumber: Int, ): Flow<PagingData<UserOrCategory>> = Pager( config = PagingConfig( pageSize = pageSize, enablePlaceholders = false, prefetchDistance = prefetchDistance, initialLoadSize = initialLoadSize, ) // сюда также закидывается RemoteMediator через "remoteMediator =" если он используется ) { UserAndCategoryDataSource( pageSize = pageSize, initialPageNumber = initialPageNumber, networkApi = networkApi, ) }.flow .flowOn(Dispatchers.IO) .stateIn(viewModelScope, SharingStarted.Lazily, PagingData.empty()) .cachedIn(viewModelScope) }
Далее фрагмент
// сначала небольшой екстеншн для flow fun <T> Flow<T>.launchWhenStarted(scope: LifecycleCoroutineScope) { scope.launchWhenStarted { [email protected]() } } // далее фрагмент class UsersFragment : BaseFragment() { companion object { // далее все константы для пейджера private const val PAGE_SIZE = 10 private const val PREFETCH_DISTANCE = 3 private const val INITIAL_LOAD_SIZE = 10 private const val INITIAL_STATEMENTS_PAGE_NUMBER = 1 fun newInstance() = UsersFragment() } // в рамках данного примера loadStateJob не нужна, но может понадобиться ее использовать, чтобы отменять таску private var loadStateJob: Job? = null private val binding by viewBinding(FragmentUsersBinding::bind) private val viewModel: UserAndCategoryViewModel by sharedViewModel() // здесь инициализируем первый адаптер UserAndCategoryAdapter, который будет показывать список private val userAndCategoryAdapter = UserAndCategoryAdapter( onUserClicked = { // логика для клика по пользователю, можно бы было использовать listiner вместо этого или любой другой способ } ) override fun getLayout() = R.layout.fragment_users override fun viewReady() { binding.run { // с помощью withLoadStateHeaderAndFooter можно задать второй адаптер StatementsLoaderAdapter, который показывает loading и error recyclerViewUsersAndCategories.adapter = userAndCategoryAdapter.withLoadStateHeaderAndFooter( header = StatementsLoaderAdapter(), footer = StatementsLoaderAdapter() ) subscribeToLoadState() subscribeToDataSource() } } private fun subscribeToLoadState() { // вместо loadStateFlow можно использовать addLoadStateListener userAndCategoryAdapter.loadStateFlow.onEach { state -> loadStateJob?.cancel() loadStateJob = lifecycleScope.launchWhenStarted { val isLoading = state.refresh == LoadState.Loading val isEmpty = statementsAdapter.itemCount < 1 val isError = state.refresh is LoadState.Error // и далее любая логика для состояний, советую залогировать состояния, чтобы посмотреть, как они работают // вообще есть такие CombinedLoadStates: refresh, prepend, append, source, mediator, и состояния LoadState: Loading, NotLoading, Error } } }.launchWhenStarted(lifecycleScope) private fun subscribeToDataSource() { // передаем все константы viewModel.getAccountStatementsDataSource( pageSize = PAGE_SIZE, prefetchDistance = PREFETCH_DISTANCE, initialLoadSize = INITIAL_LOAD_SIZE, initialPageNumber = INITIAL_STATEMENTS_PAGE_NUMBER, ).onEach { data -> // закидываем дату в адаптер userAndCategoryAdapter.submitData(data) }.launchWhenStarted(lifecycleScope) } }
далее адаптер для показа списка
class UserAndCategoryAdapter(private val onUserClicked: (String) -> Unit) : PagingDataAdapter<UserOrCategory, RecyclerView.ViewHolder>(DiffCallback) { companion object { private enum class Type(val value: Int) { USER(0), CATEGORY(1), } } // здесь буду использовать viewBinding override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = when (viewType) { Type.USER.value -> UserHolder( LayoutUserBinding.inflate(LayoutInflater.from(parent.context), parent, false) ) else -> CategoryHolder( LayoutCategoryBinding.inflate(LayoutInflater.from(parent.context), parent, false) ) } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { when (holder) { is CategoryHolder -> holder.bind( categoryData = getItem(position) as CategoryData ) is UserHolder -> holder.bind( userData = getItem(position) as UserData onUserClicked = { onUserClicked(it) } ) } } override fun getItemViewType(position: Int) = when (getItem(position)) { is UserData -> Type.USER.value else -> Type.CATEGORY.value } class CategoryHolder(private val binding: LayoutCategoryBinding) : RecyclerView.ViewHolder(binding.root) { fun bind(categoryData: CategoryData) { binding.category.setSimpleText(categoryData.title) } } class UserHolder(private val binding: LayoutUserBinding) : RecyclerView.ViewHolder(binding.root) { fun bind(userData: UserData, onUserClicked: (String) -> Unit) { binding.run { title.setSimpleText(userData.title) description.setSimpleText(userData.description) avatar.load(userData.avatar) } } } private object DiffCallback : DiffUtil.ItemCallback<UserOrCategory>() { // здесь какая то логика для сравнения элементов override fun areItemsTheSame(oldItem: UserOrCategory, newItem: UserOrCategory): Boolean { if (oldItem is UserData && newItem is UserData && oldItem.id == newItem.id) { return true } else if (oldItem is CategoryData && newItem is CategoryData && oldItem.id == newItem.id) { return true } return false } // здесь- какая то логика для сравнения, обновлен элемент или нет override fun areContentsTheSame(oldItem: UserOrCategory, newItem: UserOrCategory): Boolean { if (oldItem is UserData && newItem is UserData && oldItem.title == newItem.title) { return true } else if (oldItem is CategoryData && newItem is CategoryData && oldItem.category == newItem.category) { return true } return false } } }
и далее адаптер для показа состояний
// здесь ради разнообразия не использовал ViewBinding class StatementsLoaderAdapter : LoadStateAdapter<RecyclerView.ViewHolder>() { companion object { private enum class State(val value: Int) { ERROR(1), PROGRESS(0), } } class ProgressViewHolder(view: View) : RecyclerView.ViewHolder(view) { fun bind(loadState: LoadState) { } } class ErrorViewHolder(view: View) : RecyclerView.ViewHolder(view) { private val errorMessage = view.findViewById<TextView>(R.id.errorMessage) fun bind(loadState: LoadState) { if (loadState is LoadState.Error) { errorMessage.setSimpleText(loadState.error.localizedMessage) } } } override fun getStateViewType(loadState: LoadState) = when (loadState) { LoadState.Loading -> State.PROGRESS.value is LoadState.NotLoading -> error("Not supported") is LoadState.Error -> State.ERROR.value } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, loadState: LoadState) { when (holder) { is ProgressViewHolder -> holder.bind(loadState) is ErrorViewHolder -> holder.bind(loadState) } } override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState) = when (loadState) { LoadState.Loading -> ProgressViewHolder( LayoutInflater.from(parent.context).inflate(R.layout.layout_paging_progress, parent, false) ) is LoadState.Error -> ErrorViewHolder( LayoutInflater.from(parent.context).inflate(R.layout.item_paging_error, parent, false) ) is LoadState.NotLoading -> error("Not supported") } }
Теперь что касается пагинации в популярной библиотеке AdapterDelegates от Hannes Dorfmann
(эту часть статьи дописал через 2 года после предыдущей части и немного изменил подход)
https://hannesdorfmann.com/android/adapter-delegates
https://github.com/sockeqwe/AdapterDelegates
Делаем DataSource
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.onEach import androidx.paging.PositionalDataSource class UserAndCategoryDataSource( private val showLoader: (Unit) -> Unit, private val hideLoader: (Unit) -> Unit, private val viewModelScope: CoroutineScope, private val showErrorState: (String?) -> Unit, private val userAndCategoryUiMapper: UserAndCategoryUiMapper, private val getUserAndCategoryUseCase: GetUserAndCategoryUseCase, ) : PositionalDataSource<UserAndCategoryUi>() { companion object { private const val TAG = "UserAndCategoryDataSourceTag" private const val CURSOR_SIZE = 20 } private var cursor: String? = null override fun loadInitial( params: LoadInitialParams, callback: LoadInitialCallback<ApplicationUi> ) { logDebug("loadInitial", TAG) showLoader.invoke(Unit) number = -1 loadNext { logDebug("loadInitial onResult size: ${it.size}", TAG) callback.onResult( it, 0, it.size ) } } override fun loadRange( params: LoadRangeParams, callback: LoadRangeCallback<ApplicationUi> ) { logDebug("loadRange", TAG) loadNext { logDebug("loadRange onResult size: ${it.size}", TAG) callback.onResult(it) } } private fun loadNext(callback: (List<UserAndCategoryUi>) -> Unit) { getUserAndCategoryUseCase.invoke( cursor = cursor, size = CURSOR_SIZE, ).onEach { result -> result.onLoading { logDebug("loadNext onLoading", TAG) }.onSuccess { model, _, _ -> logDebug("loadNext onSuccess", TAG) if (model.content == null || model.cursor == null) { showErrorState.invoke("Data from server is empty") hideLoader.invoke(Unit) return@onEach } if (!model.content.isNullOrEmpty()) { callback(applicationsUiMapper.mapList(model.content!!)) cursor = model.cursor!! } delay(HIDE_LOADER_LONG_DELAY) hideLoader.invoke(Unit) }.onFailure { _, message, _, _ -> logError("loadNext onFailure", message, TAG) showErrorState.invoke(message) hideLoader.invoke(Unit) } }.launchInScope(viewModelScope) } }
Adapter, здесь пример использования адаптера с только одним типом элемента:
import com.hannesdorfmann.adapterdelegates4.paging.PagedListDelegationAdapter import org.koin.core.component.KoinComponent import org.koin.core.component.inject class UserAndCategoryMeAdapter : PagedListDelegationAdapter<UserAndCategoryUi>(DefaultDiffUtilCallback()), KoinComponent { private val userAndCategoryDelegate: UserAndCategoryDelegate by inject() var clickListener: ClickListener? = null set(value) { field = value userAndCategoryDelegate.openClickListener= { model -> clickListener?.onOpenClicked( model = model, ) } } init { delegatesManager.apply { addDelegate(userAndCategoryDelegate) } } interface ClickListener { fun onOpenClicked(model: UserAndCategoryUi) } }
Delegate:
import android.view.View import android.view.ViewGroup import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView import com.hannesdorfmann.adapterdelegates4.AbsFallbackAdapterDelegate class UserAndCategoryDelegate : AbsFallbackAdapterDelegate<MutableList<UserAndCategoryUi>>() { var openClickListener: ((model: UserAndCategoryUi) -> Unit)? = null override fun onCreateViewHolder(parent: ViewGroup): RecyclerView.ViewHolder { return ViewHolder(parent.inflateBinding(ListItemUserAndCategoryBinding::inflate)) } override fun onBindViewHolder( items: MutableList<UserAndCategoryUi>, position: Int, holder: RecyclerView.ViewHolder, payloads: MutableList<Any> ) { (holder as ViewHolder).bind(items[position]) } private inner class ViewHolder( private val binding: ListItemUserAndCategoryBinding ) : RecyclerView.ViewHolder(binding.root) { fun bind(model: UserAndCategoryUi) { binding.tvName.text = model.name binding.rootLayout.onClickThrottle { openClickListener?.invoke(model) } } } }
добавляем в ViewModel
import androidx.lifecycle.LiveData import androidx.lifecycle.viewModelScope import androidx.paging.DataSource import androidx.paging.LivePagedListBuilder import androidx.paging.PagedList import org.koin.core.component.inject class UserAndCategoryViewModel : BaseViewModel() { companion object { private const val TAG = "UserAndCategoryViewModelTag" const val CURSOR_SIZE = 20 } private val userAndCategoryUiMapper: UserAndCategoryUiMapper by inject() private val getUserAndCategoryUseCase: GetUserAndCategoryUseCase by inject() private val dataSourceFactory: DataSource.Factory<Int, UserAndCategoryUi> val adapterListLiveData: LiveData<PagedList<UserAndCategoryUi>> // этот кусок скопирован с другого класса для примера private var sortingModel: SortingModel by Delegates.observable( SortingModel( sortBy = SortingByEnum.DEFAULT, sortDirection = SortingDirectionEnum.ASC ) ) { _, _, _ -> updateSortingModel() dataSourceFactory.create() adapterListLiveData.value?.dataSource?.invalidate() } init { dataSourceFactory = object : DataSource.Factory<Int, UserAndCategoryUi>() { override fun create(): DataSource<Int, UserAndCategoryUi> { return UserAndCategoryDataSource( viewModelScope = viewModelScope, userAndCategoryUiMapper = userAndCategoryUiMapper, getUserAndCategoryUseCase = getUserAndCategoryUseCase, showLoader = { hideErrorState() showLoader() }, hideLoader = { hideLoader() }, showErrorState = { showErrorState(descriptionString = it) }, ) } } val config = PagedList.Config.Builder() .setEnablePlaceholders(false) .setPageSize(CURSOR_SIZE) .build() adapterListLiveData = LivePagedListBuilder(dataSourceFactory, config) .setBoundaryCallback(object : PagedList.BoundaryCallback<UserAndCategoryUi>() { override fun onZeroItemsLoaded() { super.onZeroItemsLoaded() logDebug("boundaryCallback onZeroItemsLoaded", TAG) hideLoader() } override fun onItemAtEndLoaded(itemAtEnd: UserAndCategoryUi) { super.onItemAtEndLoaded(itemAtEnd) logDebug("boundaryCallback onItemAtEndLoaded", TAG) hideLoader() } override fun onItemAtFrontLoaded(itemAtFront: UserAndCategoryUi) { super.onItemAtFrontLoaded(itemAtFront) logDebug("boundaryCallback onItemAtFrontLoaded", TAG) hideLoader() } }) .build() } fun refreshScreen() { logDebug("refreshScreen", TAG) adapterListLiveData.value?.dataSource?.invalidate() } override fun updateSortingModel() { logDebug("updateSortingModel", TAG) _currentSortingCriteriaData.value = getSortingTitleByModel( sortingModelUiMapper.map(sortingModel) ) } }
Укажу и используемый класс для данных, но вы можете использовать любой подход, который вам нравится, это просто один из примеров
enum class ErrorType { EXCEPTION, SERVER_ERROR, ERROR_IN_LOGIC, SERVER_DATA_ERROR, NO_INTERNET_CONNECTION, } data class ResultEmittedData<out T>( val model: T?, val error: Any?, val status: Status, val message: String?, val responseCode: Int?, val errorType: ErrorType?, ) { enum class Status { SUCCESS, ERROR, LOADING, } companion object { fun <T> success( model: T, message: String?, responseCode: Int?, ): ResultEmittedData<T> = ResultEmittedData( error = null, model = model, errorType = null, message = message, status = Status.SUCCESS, responseCode = responseCode, ) fun <T> loading( model: T?, message: String? = null, ): ResultEmittedData<T> = ResultEmittedData( model = model, error = null, errorType = null, message = message, responseCode = null, status = Status.LOADING, ) fun <T> error( model: T?, error: Any?, message: String?, responseCode: Int?, errorType: ErrorType?, ): ResultEmittedData<T> = ResultEmittedData( model = model, error = error, message = message, errorType = errorType, status = Status.ERROR, responseCode = responseCode, ) } } inline fun <T : Any> ResultEmittedData<T>.onLoading( action: ( message: String?, ) -> Unit ): ResultEmittedData<T> { if (status == ResultEmittedData.Status.LOADING) action( message ) return this } inline fun <T : Any> ResultEmittedData<T>.onSuccess( action: ( model: T, message: String?, responseCode: Int?, ) -> Unit ): ResultEmittedData<T> { if (status == ResultEmittedData.Status.SUCCESS && model != null) action( model, message, responseCode, ) return this } inline fun <T : Any> ResultEmittedData<T>.onFailure( action: ( model: Any?, message: String?, responseCode: Int?, errorType: ErrorType?, ) -> Unit ): ResultEmittedData<T> { if (status == ResultEmittedData.Status.ERROR) action( model, message, responseCode, errorType ) return this }
import com.google.gson.JsonSyntaxException import kotlinx.coroutines.withContext import okhttp3.FormBody import okhttp3.OkHttpClient import okhttp3.RequestBody import org.json.JSONObject import org.koin.core.component.KoinComponent import org.koin.core.component.inject import org.koin.core.qualifier.named import java.net.UnknownHostException import javax.net.ssl.SSLPeerUnverifiedException abstract class BaseRepository : KoinComponent { companion object { private const val TAG = "BaseRepositoryTag" } protected suspend fun <T> getResult( call: suspend () -> retrofit2.Response<T> ): ResultEmittedData<T> { return try { val response = call() val responseCode = response.code() val successCode = when (responseCode) { 200, 201, 202, 203, 204, 205, 206, 207, 208, 226 -> true else -> false } val responseMessage = response.message() val responseBody = response.body() ?: getEmptyResponse() when { successCode && responseBody !is EmptyResponse -> { dataSuccess( model = responseBody, message = responseMessage, responseCode = responseCode, ) } successCode -> { logDebug("getResult successCode", TAG) dataSuccess( model = getEmptyResponse(), message = responseMessage, responseCode = responseCode, ) } responseCode == 401 -> { logError("getResult responseCode == 401", TAG) startAuthorization() dataError( model = responseBody, message = responseMessage, responseCode = responseCode, error = responseBody.toString(), errorType = ErrorType.SERVER_ERROR, ) } else -> { logError("getResult conditions else", TAG) dataError( model = responseBody, error = null, // TODO responseCode = responseCode, message = responseMessage, errorType = ErrorType.SERVER_ERROR, ) } } } catch (exception: UnknownHostException) { logError( "getResult Exception is UnknownHostException, message: ${exception.message} stackTrace: ${exception.stackTrace}", exception, TAG ) dataError( model = null, error = null, responseCode = null, message = "No internet connection", errorType = ErrorType.NO_INTERNET_CONNECTION, ) } catch (exception: SSLPeerUnverifiedException) { logError( "getResult Exception is SSLPeerUnverifiedException, message: ${exception.message} stackTrace: ${exception.stackTrace}\",", exception, TAG ) dataError( model = null, error = null, responseCode = null, errorType = ErrorType.EXCEPTION, message = "Error while receiving data from server, encryption incorrect", ) } catch (exception: JsonSyntaxException) { logError( "getResult Exception is JsonSyntaxException, message: ${exception.message} stackTrace: ${exception.stackTrace}\",", exception, TAG ) dataError( model = null, error = null, responseCode = null, errorType = ErrorType.EXCEPTION, message = "Error while receiving data from server, data format incorrect", ) } catch (exception: java.io.EOFException) { logError( "getResult Exception is EOFException, message: ${exception.message} stackTrace: ${exception.stackTrace}\",", exception, TAG ) dataError( model = null, error = null, responseCode = null, errorType = ErrorType.EXCEPTION, message = exception.message ?: exception.toString(), ) } catch (exception: Throwable) { logError( "getResult Exception is other, message: ${exception.message} stackTrace: ${exception.stackTrace}\",", exception, TAG ) dataError( model = null, error = null, responseCode = null, errorType = ErrorType.EXCEPTION, message = exception.message ?: exception.toString(), ) } } private fun <T> dataError( model: T?, error: Any?, responseCode: Int?, message: String?, errorType: ErrorType?, ): ResultEmittedData<T> = ResultEmittedData.error( model = model, error = error, message = message, errorType = errorType, responseCode = responseCode, ) private fun <T> dataSuccess( model: T, message: String?, responseCode: Int, ): ResultEmittedData<T> = ResultEmittedData.success( model = model, message = message, responseCode = responseCode, ) private fun startAuthorization() { logDebug("start authorization", TAG) } } class EmptyResponse @Suppress("UNCHECKED_CAST") fun <T> getEmptyResponse(): T { return EmptyResponse() as T }
Код конечно не оптимален и зависит от контекста проекта, но думаю основной принцип понятен и при необходимости можно переписать под свои нужды.