跪拜 Guibai
← Back to the summary

From 2.7.0 to 3.0.0: The Flutter Rewrite That Started by Deleting Everything

Copyright notice: This article is published only on the Juejin platform. Please do not plagiarize or repost without permission. When reprinting, please cite the author and original link 🦉

Reading tip: The web version has theme and code highlighting for a better reading experience 🍉

No more talk, here's a pure screenshot gallery.

2ec237a422ede164c18a09972186c686.jpg 093617a5b0a68fe8fa1e3f9de78f9836.jpg 45db506c797969687944a841717bc1cd.jpg
d8d681fb94a4e53ef23fd35692fc3bf5.jpg b63918db8014e34f973b6bf7cc196698.jpg 653f01a0edca58f34b644493231acea9.jpg

1. Motivation

Two years ago, when I wrote the last article, Bob Music had just passed 1.0. After continuous iteration, it reached 2.7.0 in May this year, and then stopped updating.

It's not that I didn't want to change it; I didn't dare to.

This project was written while I was learning Flutter. I know exactly what the first version was like: it runs, but inside it's all IOUs. The longer I used it, the more IOUs accumulated. Let me list a few for you:

1. Cached songs stay wholly in memory; playback progress relies on a 300ms polling refresh

2. The lyric timeline unit parsing is wrong, so the scrolling rhythm never matches, ten times slower than the song

3. On startup, while clearing the upcoming playlist, it also reads it, a case of split personality

4. SQL concatenates table names, deletion logic uses broad matching, and songs can be accidentally deleted

5. event_bus flies everywhere, the UI reads the database directly, and the same state is stored in three different places

6. Any state change triggers notifyListeners for the entire tree, making the whole component tree run along

That's the 2.x state. All functions work, and I've been using it, but every time I want to add a feature, I have to dig through a pile of IOUs first.

I seriously considered incremental refactoring; I even drafted the plan to P5. Halfway through, I saw the problem: the foundation is crooked; decoration can't save it. The old code has no test protection, fixing one place breaks three, and the cost of refactoring is no lower than rewriting, with less benefit than rewriting.

And this time is different from two years ago: two years ago I was a Flutter beginner; now I'm at least experienced. Also, AI-assisted development has fully matured (more on that later).

So one late night, I froze the master branch as an archive, created a new branch, and practically deleted all the code under lib/.

Yes, deleted it all.

2. Decisions

Deleting code is a rush, but a few decisions need to be made first, or it becomes a suicide rewrite.

1. What to keep?

Only one thing: the Bilibili audio source SDK. That's the most valuable asset of the whole project: WBI signing, risk-control tokens, playurl parsing, all painstakingly reverse-engineered. It can't be rebuilt. Before integrating, I refactored it: stripped UI coupling, typed errors, and added a round of characteristic tests (signature golden values, parsing snapshots) to guarantee behavior is identical before and after.

Everything else goes. The old code's fate is git history and documentation; no long-term dual-track.

2. What state management?

First, I have to apologize for the boast from two years ago: at the end of the last article I said, "Next development, I'll definitely use GetX."

This time I broke that promise; I used Riverpod 3.

Reasoning: GetX is an all-in-one bag stuffing state, routing, and dependency injection. It's nice, but too much black magic. The 2.x combination of provider + event_bus hurt me badly. This time I wanted "compile-time errors" and "precise refreshes". Riverpod's codegen fits exactly: when a playlist changes, invalidate only that provider, no full-tree refresh. event_bus is completely banned; cross-module communication goes only through the provider dependency graph.

3. Version number?

The old version went to 2.7.0; the rewrite starts at 3.0.0. The branch is called v3, honoring the name "third generation".

4. Migrate old data?

I struggled with this; I even wrote a migration plan. In the end I cut it: the new and old database table structures are completely different, and a forced migration could easily bring old bugs into the new implementation. 2.7.0 is archived on master; old and new don't interfere.

3. Design

This time I learned my lesson: before coding, I wrote a rewrite plan, nailing down feature list, data model, state domains, and playback core, with nine decisions just on feature semantics. For example:

1. Tracks and playlist members are two different concepts: a song has one global identity, and can appear in N lists. "Like", lyrics, and download flags are properties of the song itself, naturally globally synchronized, no sync code needed. When I tap a heart in "Favorites", the same song in the playlist automatically turns red: it's not a sync; it's just that there's only one copy.

2. The playback queue is independent of playlists: allows insertion, append, duplication, reordering, and persists across starts.

3. Local song identity uses a content fingerprint: generate a fingerprint from "file size + duration + normalized title/artist" as the primary key. Rescan a hundred times, and your likes and lyrics won't be lost.

The database has just four tables, all SQL parameterized, and the Repository is the only layer allowed to touch SQL. The UI wants to read the database? No way, go through providers.

The playback core is the centerpiece. The 2.x approach of "whole song in memory + 300ms polling" must die. The new scheme is a custom proxy audio source:

1. Range requests directly to CDN, stream while downloading, sequential streaming writes to disk, automatically converts to cache after playback

2. Cache keyed by song identity, LRU eviction, 2GB limit

3. If the main link fails, automatically degrade to a backup direct link; watchdog intervenes after 15 seconds of no progress

4. Resume playback: song, progress, playback mode, queue all persisted; cold start restores

While tuning this, I hit a nasty pit: playback had no sound, and logs showed no errors. After a long investigation, I found the watchdog's Timer was declared late but never initialized. The null-pointer exception was swallowed by an upper-level catch, the flow silently broke, and a 0-byte stream was written to disk. Since then I have PTSD about "silent catches".

System media was changed. With audio_service, the media notification customization hit a template ceiling; the look I wanted couldn't be drawn. So I threw it out entirely and built a lightweight media center: a thin bridge in Kotlin and Swift, custom-drawn notifications, and full integration with lockscreen, control center, and wired headset controls. I can draw it however I want, no more fighting someone else's abstraction.

The audio sources expanded from a single Bilibili to five: Bilibili, Kuwo, NetEase, QQ, and Qianqian, with one-tap switching on the search page. Lyrics use a three-level retrieval chain: in-house direct link → open lyric library → backup sources. If automatic matching fails, manual search is available, plus time calibration.

Local scanning was redone: background isolate scanning, progress via streaming events, results appear while scanning. The UI has a full design system: all colors use tokens, hardcoded color values are banned project-wide; the background is an "aurora glass" style with three slowly drifting light spots and breathing pulse, implemented with cached layers and displacement, zero composer redraws; empty states use flat vector illustrations.

4. About This Week

The actual construction started on the day of deleting the code, and ended with the official build release: seven days, 108 commits.

The upfront design and UI prototype polishing aren't counted: that took longer than construction. Slow design, fast build: the trade-off is worth it.

This week's development experience was a completely different world from writing 1.x two years ago. The biggest variable is AI.

Two years ago, my pattern was: learn while writing, get stuck, ask AI for help, and AI code often wouldn't run because the libraries it recommended were mostly unmaintained. This time it's reversed: I first wrote architecture decisions into a document, set rules for AI: cross-module only via providers, SQL fully parameterized, colors only via tokens, static checks zero issues plus all tests green before delivery, then let AI build to the plan. I handle design decisions, code reviews, and real-device acceptance.

Beyond rules, there's memory: pitfalls hit in each module, bugs fixed, relationships between modules, all distilled into documents. The next task starts by reading memory before coding. It's like having a colleague who never quits and never forgets.

Of course, AI is no silver bullet. For parts like the playback core that heavily depend on real-device behavior, AI-written code must be verified line by line on real devices. For example, Bilibili video returns -404 when invalid; only the online world knows that, it's not in the docs, and AI can't invent it. Only real devices can speak.

Tests were used as delivery gates: five sources each have fake implementations, playback engine has doubles, and switch strategies and state machine timing have matrix tests, totaling over 170 cases. 2.x had zero tests, which is why it was allowed to rot.

Finally, I must complain about the build environment; the dirtiest work in these seven days was actually this:

1. Full toolchain upgrade: 132 dependencies major upgrades, AGP 9, Gradle 9.3.1, JDK 17 in one go

2. AGP 9 removed Jetifier, but many plugins' Kotlin versions didn't keep up, causing a cascade of errors; a certain well-known sharing plugin's new version loses classes under AGP 9, so I had to pin the old version window

3. Trying to pin an old NDK? Rejected; must be 28.2

4. Local JAVA_HOME points to JDK 8; I had to pin 17 again in the build script

5. First builds had various download timeouts; the screen full of errors looks like everything is done, but retrying works

None of these pitfalls are business logic, but each can trap you for a night. Fortunately, AI accompanied the troubleshooting, and the "environment issues eat three days" story from two years ago didn't repeat.

5. Conclusion

One week, from deleting the database to 3.0.0: five audio sources, three-level lyric chain, self-built playback core and system media bridge, over 170 tests. The original intent of two years ago, "free music, no ads", hasn't changed; only the insides have.

The biggest feeling from the rewrite is: debts always have to be paid, and paying early is easier. The day I deleted the code was genuinely satisfying.

If you want to try it, visit GitHub Releases: Bob Music.

For source code, visit the GitHub repo: bobomusic. If you like it, give a star. Thanks.

Previous Recommendations

Building a Frontend Project for Rapid Development of Tampermonkey Scripts 18+ 👍🏻 28+ 💚

During the Golden September and Silver October Hiring Season, How Should IT Workers Identify Good and Bad Companies? 70+ 👍🏻 80+ 💚

Why Did ESLint Checking Fail for This File?

Learn TypeScript Gymnastics to Easily Understand Open Source Project Code

While Others Rest, I Work Hard: Quietly Write a CLI Tool, Must Improve Efficiency, skr~ 60+ 👍🏻 110+ 💚

Master ESLint in One Article, Never Fear Project Errors Again 20+ 👍🏻 30+ 💚

What Engineering Configurations Should an npm Library Include? 40+ 👍🏻 50+ 💚

Sharing the Awesome Websites and Tools I Use in Frontend Learning and Development 40+ 👍🏻 110+ 💚

uniapp Pitfall Record (II) 130+ 👍🏻 150+ 💚

When Idle, While Slacking Off, I Asked ChatGPT to Write a Console Style Enhancement Library and Published It on npm 100+ 👍🏻 110+ 💚

uniapp First Experience Pitfall Record 30+ 👍🏻 60+ 💚

Learn JS Regular Expressions in Two Hours, Never Forget Them