Clean architecture в контексте кроссплатформенной разработки

Всем привет. В последнее время довольно много статей написано на тему clean architecture. То есть чистой архитектуры, которая позволяет писать приложения, удобные в сопровождении и тестировании. Про саму чистую архитектуру вы можете прочитать в таких замечательных статьях как: Заблуждения Clean Architecture или Чистая архитектура, поэтому не вижу смысла повторять то, что уже написано.

Для начала позвольте представиться, меня зовут Какушев Расул. Так уж получилось что я одновременно занимаюсь нативной разработкой на ios и android, а так же разработкой backend-кода мобильных приложений, в компании Navibit. Это пока еще малоизвестная компания, которая только готовится выйти на рынок продажи строительных материалов. У нас очень маленькая команда и поэтому разработка мобильных приложений целиком и полностью ложится на мои (еще не слишком профессиональные) плечи.

В моей работе часто приходится делать одно приложение на ios и android, и как вы понимаете, в силу различий платформ, часто приходится писать один и тот же функционал несколько раз. Это занимает довольно много времени, и поэтому некоторое время назад, когда я познакомился с clean architecture, мне пришла в голову такая мысль: языки kotlin и swift довольно похожи, однако платформы различаются, но в clean architecture есть domain слой, который не привязан к платформе, а содержит чистую бизнес-логику. Что будет если просто взять весь domain слой из android и перенести его в ios, с минимальными изменениями?

Что же, задумано — сделано. Я начал перенос. И действительно идея оказалась в большинстве своем верной. Сами посудите. К примеру вот один интерактор на kotlin и swift:

Kotlin (Android)

class AuthInteractor @Inject
internal constructor(private val authRepository: AuthRepository,
                     private val profileRepository: ProfileRepository) {

    fun auth(login: String, password: String, cityId: Int): Single<Auth> = authRepository.auth(login.trim { it <= ' ' }, password.trim { it <= ' ' }, cityId, cloudToken)

    fun restore(login: String, password: String, cityId: Int, confirmHash: String): Single<AuthInfo> = authRepository.restore(login.trim { it <= ' ' }, password.trim { it <= ' ' }, cityId, confirmHash)

    fun restore(password: String, confirmHash: String): Single<AuthInfo> = authRepository.restore(password.trim { it <= ' ' }, confirmHash)

    fun getToken(): String = authRepository.checkIsAuth()

    fun register(login: String,
                 family: String,
                 name: String,
                 password: String,
                 cityId: Int,
                 confirmHash: String): Single<AuthInfo> =
            authRepository.register(login.trim { it <= ' ' },
                    family.trim { it <= ' ' },
                    name.trim { it <= ' ' },
                    password.trim { it <= ' ' },
                    cityId, confirmHash)

    fun checkLoginAvailable(login: String): Single<LoginAvailable> = authRepository.checkLoginAvailable(login)

    fun saveTempCityInfo(authCityInfo: AuthCityInfo?) = authRepository.saveTempCityInfo(authCityInfo)

    fun checkPassword(password: String): Single<AuthInfo> = authRepository.checkPassword(password)

    fun auth(auth: Auth) {
        authRepository.saveToken(auth.token!!)
        profileRepository.saveProfile(auth.name!!, auth.phone!!, auth.location!!)
    }

    companion object {
        const val AUTH_ERROR = "HTTP 401 Unauthorized"
    }
}

Swift (iOS):

class AuthInteractor {
    
    public static let AUTH_ERROR = "HTTP 401 Unauthorized"
    
    private let authRepository: AuthRepository
    private let profileRepository: ProfileRepository
    private let cloudMessagingRepository: CloudMessagingRepository
    
    init(authRepository: AuthRepository,
            profileRepository: ProfileRepository,
            cloudMessagingRepository: CloudMessagingRepository) {
        self.authRepository = authRepository
        self.profileRepository = profileRepository
        self.cloudMessagingRepository = cloudMessagingRepository
    }
    
    func auth(login: String, password: String, cityId: Int) -> Observable<Auth> {
        return authRepository.auth(login: login.trim(), password: password.trim(), cityId: cityId, cloudMessagingToken: cloudMessagingRepository.getCloudToken())
    }
    
    func restore(login: String, password: String, cityId: Int, confirmHash: String) -> Observable<AuthInfo> {
        return authRepository.restore(login: login.trim(), password: password.trim(), cityId: cityId, confirmHash: confirmHash)
    }
    
    func restore(password: String, confirmHash: String) -> Observable<AuthInfo> {
        return authRepository.restore(password: password.trim(), confirmHash: confirmHash)
    }
    
    func getToken() -> String {
        return authRepository.checkIsAuth()
    }
    
    func register(login: String,
            family: String,
            name: String,
            password: String,
            cityId: Int,
            confirmHash: String) -> Observable<AuthInfo> {
        return authRepository.register(login: login.trim(),
                                    family: family.trim(),
                                    name: name.trim(),
                                    password: password.trim(),
                                    cityId: cityId,
                                    confirmHash: confirmHash)
    }
    
    func checkLoginAvailable(login: String) -> Observable<LoginAvailable> {
        return authRepository.checkLoginAvailable(login: login)
    }
    
    func saveTempCityInfo(authCityInfo: AuthCityInfo?) {
        authRepository.saveTempCityInfo(authCityInfo: authCityInfo)
    }
    
    func checkPassword(password: String) -> Observable<AuthInfo> {
        return authRepository.checkPassword(password: password)
    }
    
    func auth(auth: Auth) {
        authRepository.saveToken(token: auth.token)
        profileRepository.saveProfile(name: auth.name, phone: auth.phone, location: auth.location)
    }
}

Или же вот пример того как выглядят интерфейсы репозиториев на различных платформах:

Kotlin (Android)

interface AuthRepository {

    fun auth(login: String, password: String, cityId: Int, cloudMessagingToken: String): Single<Auth>

    fun register(login: String,
                 family: String,
                 name: String,
                 password: String,
                 cityId: Int,
                 confirmHash: String): Single<AuthInfo>

    fun restore(login: String, password: String, cityId: Int, confirmHash: String): Single<AuthInfo>

    fun restore(password: String, confirmHash: String): Single<AuthInfo>

    fun checkLoginAvailable(login: String): Single<LoginAvailable>

    fun sendCode(login: String): Single<CodeCheck>

    fun checkCode(hash: String, code: String): Single<CodeConfirm>

    fun checkIsAuth(): String

    fun saveToken(token: String)

    fun removeToken()

    fun notifyConfirmHashListener(confirmHash: String)

    fun getResendTimer(time: Long): Observable<Long>

    fun checkPassword(password: String): Single<AuthInfo>

    fun saveTempCityInfo(authCityInfo: AuthCityInfo?)

    fun saveTempConfirmInfo(codeConfirmInfo: CodeConfirmInfo)

    fun getTempCityInfo(): AuthCityInfo?

    fun getConfirmHashListener(): Observable<String>

    fun getTempConfirmInfo(): CodeConfirmInfo?
}

Swift (iOS):

protocol AuthRepository {
    
    func auth(login: String, password: String, cityId: Int, cloudMessagingToken: String) -> Observable<Auth>
    
    func register(login: String, family: String, name: String, password: String, cityId: Int, confirmHash: String) -> Observable<AuthInfo>
    
    func restore(login: String, password: String, cityId: Int, confirmHash: String) -> Observable<AuthInfo>
    
    func restore(password: String, confirmHash: String) -> Observable<AuthInfo>
    
    func checkLoginAvailable(login: String) -> Observable<LoginAvailable>
    
    func sendCode(login: String) -> Observable<CodeCheck>
    
    func checkCode(hash: String, code: String) -> Observable<CodeConfirm>
    
    func checkIsAuth() ->String
    
    func saveToken(token: String)
    
    func removeToken()
    
    func notifyConfirmHashListener(confirmHash: String)
    
    func getResendTimer(time: Int) -> Observable<Int>
    
    func checkPassword(password: String) -> Observable<AuthInfo>
    
    func saveTempCityInfo(authCityInfo: AuthCityInfo?)
    
    func saveTempConfirmInfo(codeConfirmInfo: CodeConfirmInfo)
    
    func getTempCityInfo() -> AuthCityInfo?
    
    func getConfirmHashListener() -> Observable<String>
    
    func getTempConfirmInfo() -> CodeConfirmInfo?
}

Аналогично дело обстоит и с presentation слоем, так как презентеры и view-интерфейсы на обеих платформах одинаковы. Поэтому благодаря такому переносу, моя скорость разработки увеличилась почти вдвое, так как из-за того, что на обеих платформах уже полностью сформированы domain и presentation слои, остается дело за малым — подключить специфичные библиотеки и доработать ui и data слои.

Спасибо за то что дочитали до конца. Надеюсь данная статья принесет пользу мобильным разработчикам, которые занимаются нативной разработкой. Всего наилучшего.
Share post

Comments 11

    +4
    Спасибо за то что дочитали до конца.


    Я только настроился и… все… конец. Сейчас даже Твиты длиннее)
      0
      Это моя первая статья.)) А так надеюсь со временем получится написать что-нибудь пообъемнее.
      0
      А вы пробовали этот процесс автоматизировать?
        0
        Я тоже подумал об автоматизации, и вариантов в голове возникло два: 1) DSL, генерирующий и Kotlin-код, и Swift-код; 2) транспайлер из одного в другой. Я бы сам выбрал первый вариант — в знакомом мне C#-стеке могло бы выйти несложно. Кстати, не так давно проходила статья о делании DSL на Kotlin (примером был DSL для тестовых сценариев) — похоже, так тоже можно сделать довольно быстро.
        +2
        Сложно назвать кроссплатформенной разработкой нативную разработку для двух платформ
          0

          Есть же Kotlin Native и gradle-плагин Konan, который позволяет компилить pure-kotlin код без изменений сразу в ios-фреймворк

            +1
            Все же дядя Боб рекомендует, что бы был один класс на один use case, а не один класс на 8 use case'ов как у вас. Ну и слово «Interactor» не обязательно добавлять в название класса. Ну и интерфейс репозиториев тоже странный (sendCode, notifyConfirmHashListener и т. д.), он должен отображать работу по обеспечению персистентности бизнес сущностей, а не повторять, почти один в один, интерфейс интерактора
              0
              да тут более менее еще, это репозиторий-слой тут может быть несколько однотипных запросов к примеру, а use-case это на слой выше, где можно комбинировать различные запросы из одного/нескольких репозиториев. use-case должен быть уникальным, поэтому там зачастую один-два метода
                0
                Я недавно совсем начал использовать чистую архитектуру и поэтому пока совершаю многие ошибки. Я благодарен вам за ваши комментарии)))
                0
                Спасибо вам за ваши замечания))
                0

                Почему-то я думал что это история про использование Kotlin/Native для реализации доменной логике на двух платформах

                Only users with full accounts can post comments. Log in, please.