TGViewer
Channel Public Channel
Kotlin

Kotlin

@kotlin_lib

Подборки полезного материала по Kotlin. По всем вопросам @evgenycarter
Subscribers
2.05K
Photos
274
Videos
140
Links
409

Showing posts older than #686 · Back to latest

Older Posts 17 shown
Post #684 711
🛡️ lifecycleScope - бро. Почему Coroutines не текут (почти)

В прошлом посте мы выяснили: Callback Hell = Memory Hell.
Вам нужно вручную отписываться в onDestroy, иначе this утечет вместе с Activity.

Kotlin Coroutines предложили решение: Structured Concurrency.
И в Android это реализовано через lifecycleScope и viewModelScope.

Как это работает на пальцах:


// Внутри Fragment / Activity
lifecycleScope.launch {
val user = api.getUser() // (1) Suspend point
showUser(user) // (2) Update UI
}



Сценарий: Пользователь открыл экран, запустилась загрузка (1), и он сразу нажал "Назад". Activity уничтожается.

Что происходит под капотом:

1. Lifecycle переходит в состояние DESTROYED.
2. lifecycleScope привязан к этому событию через LifecycleEventObserver.
3. Он вызывает cancel() у родительского Job.
4. Отмена каскадно летит вниз ко всем дочерним корутинам.

В чем магия спасения памяти?
Когда корутина отменяется в точке подвеса (suspend point, например, внутри Retrofit вызова), она выбрасывает CancellationException.

Стек вызовов сворачивается. Ссылки на локальные переменные и this (Activity) освобождаются немедленно.
Нам не нужно писать call.cancel() в onDestroy. Инфраструктура делает это за нас.

⚠️ НО! Есть подвох для Сеньоров (Cooperative Cancellation)

Отмена корутин - кооперативная. Если вы пишете вычислительный код, который не саспендится, lifecycleScope вас не спасет.

Пример утечки процессора (CPU Leak):


lifecycleScope.launch(Dispatchers.Default) {
// ❌ ЭТО НЕ ОТМЕНИТСЯ АВТОМАТИЧЕСКИ
while (true) {
heavyCalculation()
// Нет точек suspension (delay, yield, IO)
}
}



Activity умрет, Job перейдет в состояние Cancelling, но цикл продолжит крутиться в фоновом потоке, пожирая батарею, пока приложение не убьют.

Fix:
Вставляйте yield() или проверяйте isActive в тяжелых циклах.


while (isActive) { // ✅ Теперь мы проверяем флаг отмены
heavyCalculation()
}



Вывод:
lifecycleScope спасает от Memory Leaks (ссылок), но не от глупости в CPU-bound задачах.

Кто хоть раз забывал isActive в циклах и получал горячий телефон? 🔥

✍️ @kotlin_lib
  • 👍 3
Post #683 754
💀 Невидимый this: Как одна лямбда может утечь всю Activity

Мы привыкли считать, что Kotlin умнее Java. В Java анонимный класс всегда держал ссылку на внешний класс. В Kotlin компилятор пытается оптимизировать лямбды и делать их статическими синглтонами.

Но эта магия ломается от одного прикосновения.

Представьте код во ViewModel или Presenter:


class HeavyScreen {
private val bigData = ByteArray(1024 * 1024 * 20) // 20 MB
private val title = "Profile"

fun load() {
ExternalService.fetchData { result ->
// (1) Просто логируем результат
println("Got result: $result")

// (2) А теперь раскомментируйте эту строку:
// println("Update for $title: $result")
}
}
}



Разбор полетов:

1. Сценарий (1): Вы не обращаетесь к свойствам HeavyScreen внутри лямбды.
Kotlin компилирует эту лямбду как static instance. Ссылки на HeavyScreen нет. Если экран закроется, GC спокойно его соберет, даже если ExternalService будет грузить данные еще 10 минут.

2. Сценарий (2): Вы добавили обращение к title.
Всё. Оптимизация отключена.
Чтобы прочитать title, лямбде нужна ссылка на инстанс HeavyScreen. Компилятор скрыто передает this в конструктор лямбды.

Цепочка утечки:
ExternalService (static/singleton) -> Lambda -> this$0 (HeavyScreen) -> bigData (20 MB).

Пока ExternalService держит коллбэк - ваши 20 Мб висят в памяти, даже если пользователь давно ушел с экрана.

Как проверить себя?
Если вы передаете лямбду в объект, который живет дольше, чем ваш класс (Network Client, Singleton, Event Bus, Handler):

1. Убедитесь, что вы отписываетесь (clear references).

2. Или не захватывайте контекст (this) внутри лямбды (используйте только аргументы лямбды).

3. Используйте WeakReference, если архитектура позволяет (но это костыль).

Совет: В IntelliJ IDEA/Android Studio включите подсветку "Implicit usage of 'this'". Это спасет вам гигабайты RAM.

Кто ловил OutOfMemoryError из-за забытого коллбэка? 👇

✍️ @kotlin_lib
  • 👍 7
  • 👀 1
Post #681 702
🐢 Ваш by lazy тормозит (и вы об этом не знаете)

Мы привыкли писать так:


val formatter by lazy { expensiveInitialization() }



Это стандарт де-факто для тяжелых объектов.
Но знаете ли вы, что по умолчанию lazy использует режим LazyThreadSafetyMode.SYNCHRONIZED?

Что это значит под капотом:
Каждое обращение к переменной (даже после того, как она инициализирована!) проходит через проверку volatile поля. А при первой инициализации создается монитор (lock), чтобы гарантировать потокобезопасность.

Double-checked locking это надежно, но это оверхед.

Сценарий 1: Android Main Thread
Если вы используете by lazy внутри Activity или Fragment для инициализации View-компонентов или адаптеров, вы зря тратите такты процессора на синхронизацию. UI-поток один. Конкуренции нет. Зачем платить за лок?

Сценарий 2: Backend Request Scope
Если объект живет в рамках одного запроса, который обрабатывается одним потоком, синхронизация там тоже лишняя.

🚀 Решение:

Если вы уверены, что к переменной обращается только один поток (например, Main Thread), используйте:


val formatter by lazy(LazyThreadSafetyMode.NONE) {
expensiveInitialization()
}



Результат:

• Никаких synchronized блоков.
• Никаких volatile чтений.
• Чистая проверка if (value != UNINITIALIZED_VALUE).

⚡️ Бенчмарки:
На горячих путях (hot path), где к проперти обращаются тысячи раз в секунду, mode = NONE работает быстрее на ~15-20% за счет отсутствия memory barriers.

Третий режим:
Есть еще PUBLICATION. Это когда несколько потоков могут попытаться инициализировать значение одновременно, но победит тот, кто первый запишет. Остальные вычисления будут отброшены. Полезно, если лок стоит дороже, чем повторное вычисление.

Признавайтесь, кто пишет LazyThreadSafetyMode.NONE, а кто оставляет дефолт "на всякий случай"? 👇

✍️ @kotlin_lib
  • ✍ 4
  • 👍 1
Post #680 843
💣 Почему я запрещаю Destructuring Declarations на код-ревью

Котлиновский синтаксис val (name, age) = user выглядит сексуально. Python-style, кратко, молодежно.
Но для бизнес-логики это мина замедленного действия.

Представим классический DTO для транзакции:


data class Transaction(
val amount: Double,
val fee: Double
)



Где-то в недрах UseCase вы пишете:


val (total, commission) = getTransaction()
// Логика: total - commission



Всё работает.
Пока через полгода другой разработчик (или вы же) не решит, что поля в дата-классе должны идти в алфавитном порядке, или просто добавит новое поле в начало конструктора.

Изменение:


data class Transaction(
val fee: Double, // 👈 Поменяли местами
val amount: Double
)



Результат:
Код компилируется. Тесты (если они мокали объект, а не порядок полей) могут пройти.
Но в продакшене total теперь равен fee, а commission равна amount.
Поздравляю, вы только что начислили пользователю комиссию вместо зарплаты.

Почему это происходит?
Деструктуризация в Kotlin не смотрит на имена переменных. Она тупо вызывает component1(), component2().
Если типы полей совпадают (как Double и Double выше) - компилятор молчит.

✅ Правило Сеньора:
Используйте деструктуризацию только для:

1. Pair и Triple (где порядок очевиден из названий first/second).
2. Map.Entry в циклах.
3. Локальных переменных, которые живут 2 строчки кода.

Для всего остального (особенно Domain Models и DTO) - только явный доступ к свойствам:
val total = tx.amount

У кого в проекте уже настроен Detekt на запрет деструктуризации? 👇

✍️ @kotlin_lib
  • 👍 9
  • 💯 2
  • ❤ 1
Post #677 665
🔓 @PublishedApi: Как открыть internal и не покраснеть

В прошлом посте мы говорили, что inline функции нарушают инкапсуляцию. Но что делать, если вам реально нужно обратиться к internal свойству внутри инлайн-функции?

Компилятор ударит по рукам:
"Public-API inline function cannot access non-public declarations"

И он прав. Поскольку код инлайнится в модуль клиента, он физически не сможет вызвать ваш internal метод, так как байт-код клиента ничего не знает о ваших внутренних секретах.

Тут на сцену выходит @PublishedApi.

Смотрим код:


class FastCache {
// Обычный internal - доступен только внутри модуля
internal val backingMap = hashMapOf<String, Any>()

// ❌ ОШИБКА КОМПИЛЯЦИИ:
// Inline функция видна всем, а backingMap - нет.
inline fun getOrPut(key: String, block: () -> Any): Any {
return backingMap.getOrPut(key, block)
}
}



Чтобы это заработало, мы должны сказать компилятору: "Я понимаю риски, сделай это поле публичным в байт-коде, но скрой его в IDE от чужих глаз".

Как надо:


class FastCache {
@PublishedApi // 👈 Магия здесь
internal val backingMap = hashMapOf<String, Any>()

inline fun getOrPut(key: String, block: () -> Any): Any {
return backingMap.getOrPut(key, block) // ✅ Работает!
}
}



Что происходит Under the Hood?

1. В Source Code: Поле остается internal. Если кто-то попробует написать fastCache.backingMap из другого модуля - IDE не подскажет, а компилятор выдаст ошибку.

2. В Bytecode: Поле становится public (или генерируются публичные акцессоры). Это нужно, чтобы заинлайненный код на стороне клиента мог технически выполнить вызов.

⚠️ Warning для архитекторов:
Используя @PublishedApi, вы подписываете кровью контракт о бинарной совместимости.
Если вы в следующей версии библиотеки переименуете или удалите backingMap, код всех клиентов, кто заинлайнил вашу функцию getOrPut, упадет с NoSuchMethodError или NoSuchFieldError в рантайме (даже без перекомпиляции).

Вердикт:
Инструмент мощный для создания zero-overhead оберток, но требует дисциплины. Меняете @PublishedApi поле? Поднимайте мажорную версию библиотеки.

Кто пишет свои либы, часто приходится так "оголять" кишки классов?

✍️ @kotlin_lib
  • 👍 3
Post #676 709
Обратная сторона inline функций.

Все знают, что inline, это круто для лямбд (экономим на создании объектов). Но многие лепят это ключевое слово куда попало, думая, что это аналог static final или просто "ускоритель".

Сейчас я объясню, почему за это бьют по рукам.


🛑 Вы злоупотребляете inline. Остановитесь.

В Kotlin inline - это священная корова. Мы используем его, чтобы убрать оверхед при работе с лямбдами и получить доступ к reified.

Но у этой магии есть цена, о которой часто забывают, пока приложение не распухнет или не упрется в лимиты методов.

1. Code Bloat (Раздувание байт-кода)
Вспомним механику: компилятор берет тело инлайн-функции и копипастит его в каждое место вызова.
Если ваша функция занимает 5 строк и вызывается 100 раз - это ок.
Если функция занимает 50 строк сложной логики и вызывается 100 раз - вы только что добавили ~5000 строк инструкций в свой classes.dex или JAR.

Это не только увеличивает размер артефакта, но и бьет по Instruction Cache процессора. Процессор любит короткий, горячий код, который влезает в кэш.

2. Нарушение инкапсуляции (Synthetic Accessors)
Это самый коварный момент.
Допустим, у вас есть inline функция внутри класса, которая обращается к private полю:


class Manager {
private var token: String = "secret"

inline fun execute(action: () -> Unit) {
println("Token: $token") // Обращение к приватному полю
action()
}
}



Что происходит под капотом?
Код функции execute вставляется в место вызова (во внешний мир). Но этот внешний код не имеет права читать private token.
Чтобы это сработало, компилятор Kotlin тихо генерирует публичный синтетический метод (акцессор): access$getToken$p(), который возвращает значение.

Фактически, ваша приватность убита. Любой Java-код в том же пакете теоретически может дернуть этот акцессор.

3. Невозможность скрыть стектрейс
При краше внутри заинлайненной функции стектрейс иногда выглядит странно, указывая на строку вызова, а не на логику внутри функции (хотя LineNumberTable старается помогать, но в сложных цепочках отлаживать это - боль).

📝 Чек-лист Сеньора:
✅ Используйте inline только если принимаете функцию-лямбду как аргумент.

✅ Используйте inline, если жизненно нужен reified.

❌ Не ставьте inline для обычных маленьких функций без лямбд (JIT сам их заинлайнит при выполнении, если посчитает нужным, не мешайте ему).

❌ Избегайте обращения к private членам внутри больших inline функций.

У кого проект худел на пару мегабайт после чистки лишних inline? Делитесь историями 👇

✍️ @kotlin_lib
  • 👍 3
  • ❤ 1
  • 👎 1
Post #673 705
🚀 Sequence не всегда быстрее. Разрушаем мифы оптимизации

Каждый Kotlin-разработчик знает мантру: "Если цепочка операторов длинная, бери Sequence, чтобы избежать промежуточных коллекций".

Это правда. Но часто на код-ревью я вижу asSequence() на списках из 50-100 элементов. И вот тут начинается оверхед, о котором забывают.

В чем подвох?

Операторы над Iterable (map, filter) - это inline functions.
Когда вы пишете list.filter { ... }, компилятор фактически вставляет код цикла for прямо в место вызова. JIT это обожает, процессор счастлив, аллокаций минимум (кроме результирующего списка).

Операторы над Sequence, это создание объектов.
Вызов .map { ... } создает новый инстанс TransformingSequence. Вызов .filter { ... } - FilteringSequence.
Это цепочка объектов-декораторов. Каждый элемент протаскивается через виртуальные вызовы next().

📉 Бенчмарки (JMH, List vs Sequence):

Допустим, у нас простая цепочка: filter + map + first.

1. Маленький список (10-100 элементов):
🔵Iterable: Быстрее в 1.5 - 2 раза.
🔵Причина: создание объектов-оберток для Sequence стоит дороже, чем тупой проход циклом и создание мелких промежуточных ArrayList'ов.


2. Средний список (~1 000 элементов):
🔵Паритет. Разница становится невидимой.


3. Гигантский список (100k+ элементов) или тяжелые операции:
🔵Sequence: Win. Здесь ленивость спасает от OutOfMemory и лишней работы.



💡 Не ставьте asSequence() по дефолту везде.

1. Если коллекция маленькая (помещается на экран телефона) - Iterable.
2. Если вы делаете map, а потом сразу toList() - Iterable (Sequence тут просто лишний посредник).
3. Если стриминг данных, чтение файла построчно или поиск первого совпадения в огромном массиве - Sequence.

Оптимизация должна быть осознанной, а не рефлекторной.

А вы используете Sequence по дефолту или только там, где профайлер подсветил? 👇

✍️ @kotlin_lib
  • ❤ 4
  • 👍 2
Post #672 760
Скрытый боксинг value-классов. Мы всё еще аллоцируем?

Все любят value class (бывшие inline-классы). Обертка без оверхеда, типизация примитивов, красота. Но сеньор должен знать, когда эта магия ломается.

Смотрим код:


@JvmInline
value class UserId(val id: String)

fun processUser(id: UserId) { ... } // (1)
fun <T> logItem(item: T) { ... } // (2)

val uid = UserId("101")
processUser(uid) // Всё ок, передается String
logItem(uid) // 💥 А вот тут — боксинг!



Почему?
В строке (2) функция принимает дженерик T. JVM не знает, что там внутри value class, и вынуждена создать объект-обертку (instantiate UserId), чтобы передать его как Object.

Где еще ловим боксинг?

1. Использование внутри коллекций (List<UserId>).
2. Реализация интерфейсов (value class UserId : IEntity).
3. Nullable-обертки над примитивными value-классами (value class Code(val x: Int) -> Code?).

Вывод: Используйте value class для доменной выразительности, но не обманывайте себя, что это всегда zero-allocation. Если гонитесь за перформансом в горячих циклах, проверяйте байт-код или профайлер.

✍️ @kotlin_lib
  • 👍 3
Post #671 934
💀 ThreadLocal в мире Coroutines: Цена магии

Все знают: ThreadLocal привязан к потоку. Корутины могут прыгать между потоками. Это конфликт.
Большинство знает решение: asContextElement().
Но немногие задумываются, как именно это работает и почему злоупотребление этим механизмом может просадить пропускную способность сервиса.

Проблема: M:N Threading

В мире корутин один и тот же бизнес-процесс может начать выполняться на Thread-1, приостановиться (IO), и продолжить на Thread-2.
Если вы положили данные в ThreadLocal (например, RequestID для логирования) и не передали их в контекст корутины - после саспенда вы эти данные потеряете. Или, что хуже, прочитаете "грязные" данные от другой корутины, которая использовала этот поток ранее.

Решение: ThreadContextElement

Kotlin предлагает мост: ThreadLocal.asContextElement(value).
Выглядит просто, но под капотом происходит постоянное перекладывание данных.


val myTL = ThreadLocal<String>()

launch(Dispatchers.Default + myTL.asContextElement("foo")) {
println(myTL.get()) // "foo"
delay(10) // Suspend -> поток освобождается
println(myTL.get()) // "foo" (хотя поток может быть уже другим)
}



Что происходит в DispatchedContinuation?

Каждый раз, когда корутина возобновляется (resume) на потоке, срабатывает механизм перехватчиков.

1. Mount (updateThreadContext): Перед выполнением блока кода значение из контекста корутины принудительно устанавливается в ThreadLocal текущего потока. Старое значение потока запоминается.
2. Execution: Код корутины выполняется.
3. Unmount (restoreThreadContext): Как только корутина снова саспендится или завершается, в ThreadLocal потока возвращается старое значение.

Это гарантирует, что мы не загрязняем потоки пула.

📉 Скрытый оверхед (Performance Penalty)

Это не бесплатно. Если у вас в стеке корутины 5 элементов ThreadContextElement (MDC, SecurityContext, Tracing, TenantId и т.д.), и ваша корутина делает 100 саспендов (например, частые мелкие IO или yield()), то 100 раз произойдет цикл:
Save Old State -> Set New State -> Run -> Restore Old State.

На высоконагруженных системах (High Throughput) это создает ощутимое давление, так как эти операции часто включают работу с ThreadLocalMap, что не так быстро, как доступ к регистрам.

Best Practices для Senior-разработчика

1. Не используйте ThreadLocal как шину данных. Передавайте явные параметры в функции (context receivers в будущем помогут).
2. Ограничьте Scope. Используйте withContext(myTL.asContextElement()) только вокруг тех участков кода, где это действительно нужно (например, вызов легаси библиотеки, зависящей от ThreadLocal), а не на уровне всего GlobalScope или корневой корутины.
3. MDC Integration. Для логирования лучше использовать специализированные библиотеки, которые умеют эффективно работать с контекстом, или копировать контекст только на границах системы, а не таскать его везде.

Вывод: asContextElement - это костыль для совместимости с Thread-bound миром. В чистом Coroutine-мире данные должны течь через аргументы или CoroutineContext, но не через ThreadLocal.

#kotlin #coroutines #concurrency #jvm #senior

✍️ @kotlin_lib
  • ❤ 3
  • 👍 3
Post #670 888
🩸 Value Classes: Когда «оптимизация» убивает перформанс

Мы привыкли думать, что value class - это серебряная пуля для Type Safety без оверхеда. Обернули Int в UserId, и в рантайме это просто int, верно?

Не всегда. Есть сценарии, где value class начинает вести себя хуже, чем обычный data class, порождая скрытый боксинг на ровном месте.

Рассмотрим классический кейс: попытка написать обобщенный обработчик.


@JvmInline
value class UserId(val id: Int)

fun <T> handle(item: T) {
// Какая-то логика
println(item)
}

fun main() {
val uid = UserId(42)
handle(uid) // <--- Здесь происходит магия (плохая)
}



Что происходит под капотом?

В момент вызова handle(uid) компилятор сталкивается с проблемой. Функция handle ожидает T (который в JVM стирается до Object), а UserId в скомпилированном виде - это примитив int.

Чтобы передать int туда, где ожидается Object, JVM обязана его упаковать.

1. Создается экземпляр-обертка UserId в куче (heap allocation).
2. Этот объект передается в функцию.
3. Если внутри функции мы снова кастуем его к конкретному типу, происходит анбоксинг.

Еще хуже: Интерфейсы

Если ваш value class реализует интерфейс, и вы работаете с ним через ссылку на этот интерфейс - вы гарантированно получаете боксинг.


interface Identity {
fun raw(): Int
}

@JvmInline
value class GroupId(val id: Int) : Identity {
override fun raw() = id
}

fun process(id: Identity) { ... } // 100% боксинг при вызове



Почему это важно?

Если вы используете value classes в горячих циклах (hot paths) или высоконагруженных стримах, думая, что экономите память, использование их в качестве дженериков или через интерфейсы приведет к обратному эффекту:

🖤GC Pressure: Вы создаете короткоживущие объекты-обертки тысячами.
🖤Снижение производительности: Аллокация + боксинг/анбоксинг стоят дороже, чем передача ссылки на обычный объект.

Как лечить?

1. Избегайте интерфейсов на value classes, если критична производительность.
2. Специализируйте функции. Вместо fun <T> handle(t: T) пишите перегрузки для конкретных value типов, если это возможно.
3. Ждите Project Valhalla. Настоящие примитивные классы в JVM решат эту проблему фундаментально, но пока мы живем с ограничениями Type Erasure.

Итог: value class идеален для доменной модели и сигнатур функций, но будьте предельно осторожны, как только они попадают в полиморфный контекст.

#kotlin #performance #jvm #senior

✍️ @kotlin_lib
  • 👍 4
  • ❤ 2
Post #668 889
Kotlin: val != Immutable? 🤔

Многие новички (и не только) живут с убеждением, что ключевое слово val гарантирует неизменяемость данных. Но так ли это на самом деле?

В недавней статье на ProAndroidDev разбирают популярное заблуждение: val - это read-only (доступ только для чтения), но никак не immutable (неизменяемость).

Вот два кейса, когда ваш «неизменяемый» val может измениться:

1️⃣ Изменяемость самого объекта
val гарантирует только то, что ссылка на объект останется той же. Но если объект внутри изменяемый - его состояние можно менять без проблем.


val list = mutableListOf(1, 2, 3)
list.add(4) // Ссылка та же, содержимое изменилось


2️⃣ Кастомные геттеры
Это самый коварный момент. Свойство val может возвращать разные значения при каждом обращении, если у него переопределен get().


val random: Int
get() = Random.nextInt()


В статье также приводят интересную статистику: в опросе 41% разработчиков ответили, что считают val именно immutable, что технически неверно.

По-настоящему неизменяемым объект становится только тогда, когда он состоит из примитивов или других неизменяемых объектов (например, Data Class, где все поля val и нет ссылок на мутабельные типы).

https://proandroiddev.com/the-val-property-immutable-in-kotlin-2e4cf49207d0

✍️ @kotlin_lib
  • 🤡 4
  • 👍 3
  • 🫡 1
Post #667 906
Debounce vs Sample в Kotlin Flow

Ну что ж, пора снова погрузиться в мир Flow! Сегодня мы выносим на первый план два недооценённых инструмента: debounce и sample.

Про debounce многие из вас уже слышали, а вот про sample — гораздо реже. И, если быть честными, некоторые вообще используют debounce неправильно. Так что сейчас мы разложим всё по полочкам и сделаем эти концепции предельно понятными. Погнали! 🔥

https://proandroiddev.com/debounce-vs-sample-in-kotlin-flow-a89b4a94c893

✍️ @kotlin_lib
  • 👍 2
Post #666 782
Маленький экран — серьёзный вызов!

В VK мобильные разработчики создают опыт, который помещается в карман, но работает на миллионах устройств. Узнайте об их подходах к сложным задачам и ключевых результатах. По ссылке — ролики и даже вакансии!
Post #656 1.1K
Ktor Server Fundemantals. Часть 1

Освойте разработку бэкенда на Ktor с использованием Kotlin! Эта серия материалов охватывает основы Ktor, маршрутизацию, обработку запросов, аутентификацию и другие ключевые концепции, которые помогут вам эффективно создавать надежные серверные приложения.

источник

✍️ @kotlin_lib
  • 👍 3
  • 🥰 1
Older posts →
Threads Profile ViewerView any public Threads profile without an account.Open ThreadLook →Writing with AI? Make it sound human.Metric37 rewrites AI drafts so they read naturally. Free AI detector, 1,500 words free.Try Metric37 →