[Music] hello my name is Leland Richardson I'm a software engineer on the Android UI toolkit team more specifically I work on the compose runtime and the composed
compiler so earlier this year we open source compose at Google i/o and since then we've been developing it out in the
open as part of the Android Open Source project as app developers the expectations around UI development have really grown today we can't really build an app and meet the users expectations without having a polished user interface animation motion all of these things are things that didn't exist ten
years ago as an expectation from the user so compose we believe is a modern UI toolkit that really sets yourself so that's app developers up for success in this new landscape so today what I want to talk about is what problem specifically does just compose solve what were
the reasons behind some of our design decisions and how can that help you as an app developer additionally I want to talk about the mental model of compose I want to talk about how you should think about code that you write in propose how you should shape your
api's and then finally I want to talk about some of the implementation details and how compose actually works under the hood and explain what's actually happening here so let's get started what problems does compose solve and really to frame this what I want to talk about today is
separation of concerns separation of concerns is kind of a well-known software design principle it's one of the fundamental things that we learn as app developers and really it's it's an age-old kind of thing of 40 years ago for more than 40 years ago when separation of concerns was
originally postulated it was actually framed in terms of two other words coupling and cohesion and so what I wanted to do is it I want to talk about separation of concerns in terms of this I think it's a little bit more concrete and can help us understand exactly
what we're talking about so abstractly we when we write code we think of our application in terms of modules and we might think of our module in terms of multiple units so our application has several of these modules and between them we can think of these dependencies as
as coupling basically there's there's ways in which parts of one module influence the other and one way to think of this is if I make a change to some code somewhere how many other changes to other files am I going to have to make and that's coupling and
in general what we want to do is we want to reduce coupling as much as possible and sometimes coupling is actually implicit there there's a dependency that we're relying on or something that we're relying on this not actually very clear but something breaks because of a change that
happens somewhere else on the other hand we have cohesion and cohesion is really about how the units inside of a given module belong to one another they're related to one another and and cohesion is generally seen as a good thing and so one way to think of this
is that separation and concerns is really all about grouping as much related code together as possible so that our code can be maintainable over time and really scale as their application grows so framing this in terms of something more familiar to you let's let's talk about a kind
of common situation we have a view model here on the left and then we also have a an XML layout and the view model is really providing data to this layout where we have a view that we need to populate with something and it turns out there's actually
a lot of dependencies hidden here there's a lot of coupling between the view model and the layout and one of the more familiar kind of ways that you can see this is through fine view by ID what we're doing is we're we're trying to understand what the the
XML layout is actually defining finding specific elements into it and piping data through it we might even more subtly depend on things that happen in layout XML we actually might rely on a certain structure that was find their and so we have to keep these things in sync
as our application grows and really our application can grow a lot these layout xml's get very very large we have very large complicated you eyes and they're dynamic too so sometimes one element might leave the view hierarchy at runtime but not statically and and that's this leads to
null reference exceptions and things like that so one of the one of the fundamental things here is that we have a view model defined in Kotlin and then our layout XML is defined in XML and so because of this difference in language there's actually a force line of
separation here even though the view model and and the layout XML can sometimes be very very intimately related in other words they're very tightly coupled so what if we started to define the the layout the structure of our UI in the same language what if we chose Kotlin
now because we're in the same language some of these dependencies might start to become more explicit and even more we can start to refactor some code and and move things over to where they belong and and actually reduce some of that coupling and increase some of the cohesion
now some of you might be thinking about what I'm saying here and be a little bit skeptical am i saying that we should mix logic with our UI well here's the thing uh as framework authors we actually can't perfectly separate your concerns for you this is something that
only really you can do you have you have parts of your logic that will not escape the UI they are part of your UI and we actually can't prevent that but what we can do is provide you with tools to make that separation easier and so I'm here
today to try and convince you that that tool is a composable function and actually this this might sound a little bit less convert controversial than it is if you the composable part it's just a function and a function is something that you've been using probably for a long
time to separate concerns elsewhere in your code and the skills that you have acquired to do that type of refactoring and writing reliable maintainable clean code those same skills apply to composable functions so today I want to I want to talk about the anatomy of a composable function
a little bit and try to kind of help you understand how how to think about these things so here's an example of a composable function and it receives data as as parameters right we have this app data class it comes in and we want to think of the
the parameters that come into a composable function really as a mutable data it's data that we the the composable function really shouldn't be changing we should just be treating it as transform function of that data now because of that we can use any code that we want to
in Kotlin to take that data create derive data from it and then use that and describe our hierarchy here here in this function and this means that we call other composable functions and those invocations represent the UI and our hierarchy also we're able to use all the language
level primitives that Kotlin already has in order to do things dynamically so we can use if statements and for loops for control flow and and dealing with kind of the more complicated logic that our UI might have and then finally I want to point out here that we're
leveraging Kotlin is trailing lambda syntax so body here is a composable function that has a composable lambda as a parameter and that ends up implying some sort of hierarchy or structured and so body is something that wraps these set of items here so you've probably heard us say
the word declarative declarative is is a buzzword but it's an important one and I want to describe what we mean by that and usually when we talk about declarative we're talking about it in contrast to imperative programming so let's look at an example to understand this more what
if we had a UI where we like a mail or a chat application where we have like an unread messages icon and so if there are no messages we render a blank out envelope if there are some messages we put some paper in it and maybe we're a
little bit cutesy and if there are over a hundred or something we show some fire and stuff like that so with an imperative interface we might write an update count function something like this where what we do is we get in the new count and and we go
through and we figure out how we're supposed to poke at this UI in order to make it reflect the proper state and it's actually there's a lot of corner cases here and this logic isn't easy even though it's like a relatively simple example and so if you take
this logic and instead write it in a declarative interface you might end up with something like this and so here what we're doing is we're saying okay if the counts over 99 show fire if the console were zero show paper if the console over zero render a badge
with this count and that is what I mean when when when we talk about a declarative API if you want to think about it so as a UI developer the things you need to think about one given this data what UI do I want to show how do
I respond to events and and and make my UI interactive and then here's the critical thing we no longer need to think about how our UI changes over time what happens is when when we get in the data we show what it should look like we show what
the next state is and then the framework takes controls how to get from one state into the other and so now we no longer need to think about it and that's the critical piece so describe the UI based on the provided parameters and understand that the composable function
it's it's one function definition but it describes all possible states of your UI in one place it's locally defined and that kind of leads into what we mean by composition so with a name like compose and an annotation called composable seems like composition is kind of an in
concept here so I want to talk more about that and really one of the things that we're talking about here is that our model of composition differs from the the model of composition that inheritance follows and they're both types of compositions so what we're talking about here is
a different type so let's go through an example for this as well let's say we have a view and the view has like we want to create an input and so we use view as our base class and then we want to validate input and so we create
a subclass of input to do that and we want to date input and we want to use the the validation of a date and so we subclass validated input here as well but then we run into a problem when we want to create a date range input we
now have we have two dates so we kind of want to validate two dates separately so maybe we want to subclass date input but there are two of them so we can't really do that and so we run into this limitation around inheritance that we have to have
one parent that we inherit from in compose the the problem is is simpler so when we create our validate input we just call input in the body of our function we can decorate it with with something for validation and then when we create a data input we end
up calling validated and put as well and now when we run into the date range input we no longer have a problem it's just two calls and so there's no single parent that we compose on to an in composes composition model and that solves this problem another type
of composition problem is what I would call containment right so we want to have this fancy box which is a view that kind of decorates other views and we might have some other views here like story and edit form and then we want to make a fancy story
in a fancy edit form but what do we do do we inherit from fancy box or do we inherit from story it's unclear because again we need it one parent for that inheritance chain and so compose handles this really well we have a composable lambda as children and
that allows us to define something that wraps another thing so now when we want to create fancy story we just call story inside of the children of fancy box same with fancy edit form this is composers composition model another thing that compose accomplishes really well is encapsulation this
is what you should be thinking about when you think when you make public api's of composable functions and the the public api of a composable is really the the set of parameters that it receives and and those are given to it right so it doesn't have control over
them they're just provided as data on the other hand a composable can manage and create state and then it passes that state along with potentially some data that it received down to other composable zazz parameters now because it's managing that state adam talked about this yesterday if you
want to make a change to that state you can allow your children composable x' to signal that change up towards you via callbacks and finally we want to talk about something called recomposition and this is basically our way of saying that any composable function has this special ability
to get reinfected at any time and so what this means is that you if you have this very large composable hierarchy what what happens is when parts of your hierarchy change you don't want to have to reinvent iyer hierarchy and so composable functions are sort of restartable in
this way and you can actually leverage this to do some pretty powerful things so here's a bind function that is you know maybe something you would see today in android development so we have a live data that we want to sort of subscribe our view to and so
to do that we we end up calling the observe method with a lifecycle owner and then we pass in this lambda and that lambda is going to get called every single time the live data updates and when that happens we might want to go and update our views
with compose we can actually kind of invert this relationship so in compose we would have a similar messages opposable and it would receive a live data and here we call composes observe method and observed us two things here first what it does is it unwraps that that live
data and returns the the the current value as as its return value and that means you can use it in the surrounding body of the function but it also does something else it implicitly well it subscribes that live data to the composable that it's being unwrapped in and
so that means that instead of providing a lambda you just now know that this this composable function will recompose every time live data changes looking at a simpler example of this let's let's uh let's imagine that we have a a simple counter composable and so here we introduce
a piece of state which is our count and state is as a function and compose that returns an instance of this state class and the state class is annotated with a p– model and at what app model does is it it it means that every property of that
class is now the reads and writes to that property are our observable and so what compose does is when you're executing your composable function if you read one of these model instances compose will automatically subscribe the surrounding scope to rights to that that model so that means that
this example is self-contained we have a counter that will get recomposed every time the value of that model has changed okay so we just talked about a lot of capabilities that composable functions have let's start talking about how it's actually implemented before we do that i'm going to
get some water okay small disclaimer everything I'm about to say is an implementation detail and it's subject to change in fact it's very likely to change but it's fun to talk about but but the important thing that I want to say is that understanding this is not required
to use compose what I'm trying to do is just sort of satisfy your intellectual curiosity here and also if you really want to dive into this and understand what's happening this is sort of a good primer so we see this at composable annotation and a lot of slides
what is it actually doing I want to make an important point here which is that composed is not an annotation processor how compose works this through a Kotlin compiler plugin and we work in the type checking phase and in the code generation phase of Kotlin so there's no
annotation processing happening the annotation here is actually more closely related to a language keyword and so I'm going to describe it in terms of an analogy which is the suspend keyword Kotlin suspend keyword operates on function types this means that you can have a function declaration that's a
suspend we can have a lambda we can have a type compose works in the same way we can alter function types and the important point here is that when you annotate a function type with that composable you're changing that type so the same function type without the annotation
is not compatible with the type with the annotation it's a different type additionally suspend requires a calling context this means that you can only call suspend functions inside of another suspend function composable works the exact same way and this is because there's a calling context object that we
need to thread through all of the invitations and so I'm going to talk about what that object is what is what is this calling context thing that we're passing around and why do we need to do it well the implementation of this object actually has some data structures
in it that are very closely related an existing data structure called the gap buffer most of you probably aren't familiar with gap buffers but if you work with text editors you might know them they're commonly used there so to describe what a gap buffer is a gap buffer
really implements a list it's a sort of collection interface and it has a sort of current index or cursor and the way we implement this is with a flat array in memory and so that that flat array is necessarily larger than the collection of data that it represents
and so the the space in that array that's unused we refer to as the gap now as we execute our composition our composable hierarchy we can we can appeal to this data structure and we can insert things into it and so you can think of the cursor as
your current point of execution and your hierarchy and so as we go through execution we can insert items insert or another one insert items ok and so now let's imagine that we're done executing the hierarchy at some point we're gonna go and we're going to recompose something and
so we're going to reset the cursor to the top and then we're going to go through execution again and at this point we're able to do a few things we can look at the data that's there and we can do nothing if we decide we can update the
value or we can decide that the the structure of the UI is changed and then we want to make an insert so this is the important thing at this point what we do is we move the gap to the current position and now we're able to make inserts
at that point so we keep going keep making inserts now the important thing to understand about this data structure is that all of the operations that we just talked about get move insert delete all of those are constant time operations except for moving the gap moving the gap
is the expensive thing so the the reason we chose the status structure is because we're making a bet the bet is that UI is on average don't actually change structure very much when we have dynamic UIs they change in terms of the values that are there but they
don't actually change in structure and when they do they typically change in big honks and so doing this this gap move at that time is a good trade-off okay so let's look at this example we have the counter here and this is sort of the code that we
would write this is kind of the example from earlier but let's see what the compiler does so when we see this composable annotation what we do is we we actually insert sort of additional parameters into this function and so we pass in this composer object through and that
composer object is what kind of contains this gap buffer thing you also might hear me refer to it as a slot table just sort of think of it as the same thing and so we also insert some calls in the body of this composable so we're gonna call
this composer dot start method and we're gonna pass it in this key and I'm gonna talk about that in a second another thing that we're doing is we're passing that composer object into all of the composable indications that are in the body of this function so we're sort
of threading it through and we have these keys here so these are sort of these arbitrary looking integers but the way to think about this correctly is that this integer represents sort of like a hash of the source position that this call site represents so this is sort
of unique to each call site so when we go through the execution of this composable we go through and we call start and start inserts a group object into the into the slot table we go through we call State State inserts its own group object and then the
value that State returns is a state instance it also stores that and into the into the slot table then we move on to button button is going to store a group as well and then it's going to store each of its parameters and button might have this arbitrary
implementation we don't really know and it's going to also use the slot table during that time and when it's done we're gonna then call composer dot end and so you can see here that this the status structure is sort of holding all of these objects from this whole
composition and it's sort of it's sort of the entire tree in execution order it's like a depth-first traversal of the tree now all of those group objects that we just saw what are they therefore they're sort of taking up a lot of space right so actually those those
group objects are really important to manage the moves and the the inserts that might happen with the dynamic UI but we're compiler so we actually know what code looks like that changes the structure of your UI so we can conditionally insert those those groups and most of the
time we find that we don't actually need them so we don't actually have to insert that many groups into the slot table to show an example of a case where we do let's look at some conditional logic here so here's a composable it has this get data function
that returns some result and renders a loading loading composable in one case and header in a body in another case so here we see that we're inserting separate keys for the first branch of the if statement and the second branch and when we go through and execute it
let's say the first time this runs the result is null and so then we go and we run the loading screen now the second time we run it let's pretend that feed item is like sort of the result here so it's not null and at this point we're
going to go into the second branch of the estate 'men and so this is where the interesting thing happens at this point we call composer at start and it has a group with key 4 5 6 and it sees that the group in the slot table of 1
2 3 doesn't match so now it knows that the UI has changed in structure so what we do is we move the gap to the current cursor position and then we extend the gap across the the UI that was there before so we kind of get rid of
it and now we insert the the new UI the header and the body and so one way to look at this is the the overhead of the if statement in this case was a single slot entry in the slot table and it was this group and by just
inserting this single group what this does is this allows us to have arbitrary control flow in in our UI and allows us to manage it and appeal to this this sort of cash like data structure while we move through the execution of the UI and so this concept
is something that we call positional memoization and positional memoization is sort of a new thing but this is sort of the concept that composers built from from the ground up and I want to talk about what this means so memoization is kind of like a fancy sounding word
but what it really just means is normally we have global memoization and what memoization means is that we are caching the result of a function based on the inputs of that function so an example of positional memoization here might be we have this computation that we're doing inside
of a composable function we're taking in some string items and a query and we're performing some sort of a filter operation on it we can wrap this calculation in a call to memo and we can pass in items and query to that to that call and so memo
is something that knows how to appeal to the slide table and what it does is it looks at items and there's nothing there this is the first time we're running it so all it does is just store it and we look at query we store that and then
we run the calculation and we store the result and then we pass it back so that was fine but the second time we execute it that's when the interesting thing happens so when we execute again memo goes and looks at the the new values that are being passed
in and compares them with the old values and if neither of them have changed then we can skip the calculation and just return the previous result and so that's positional memorization but the interesting thing here is that it was really cheap we only have this to store one
previous invocation and this calculation could happen all over you your UI and you're storing it positionally so it only stores it for that location and this is the the signature of the memo function memo here can take any number of inputs and then some sort of calculation function
but there's an interesting degenerate case here which is when there are zero inputs one of the things we can do is we can sort of deliberately misuse this API we can we can memorize a an intentionally impure calculation like say math dot random and if you are doing
this with global memorization this would make no sense but with positional memorization it kind of ends up taking a new semantics so here we have math dot random which is memorized and we stored in this value X and for every time we use app in our in our
composable hierarchy there will be a new math dot random value that's returned there but every single time that composable recomposes it will be the same math dot random return value so what this sort of gives rise to is like a persistence and that persistence ends up giving rise
to state and this is what the state function actually is State is just a call to memo around the state constructor and so what that means is that you'll get the same instance of State across every invocation of it and that's kind of what we want so let's
move into talking about the the way that we store the parameters to composable functions so here we have a Google composable which takes in a number this is kind of a silly example but we're calling an address composable and we're just rendering an address here so we're calling
it a few text nodes underneath it when we look at how this data ends up getting stored in the slot table we ended up seeing some redundancies so the Mountain View and California that we added in the address invocation ends up getting stored again in the in the
underlying text invitations so it turns out that we can actually get rid of this redundancy by adding another parameter to composable functions at the compiler level so here we add the static parameter and this is a bit field that indicates whether or not a given parameter is known
by the runtime to not change and if it's known to not change then there's no need for us to store it so we can see here in this Google example we can pass a bit field that says none of these parameters are ever changing and then in address
we can do the same thing and pass it into text now all of this bitwise logic is like hard to read it's confusing and there's no intention of you understanding this compiles compilers are good at this humans aren't and this is exactly the kind of thing you want
to compile are doing so let's go back to our top-level example here and we can see that this this redundant information and we no longer need this store but additionally there are all these all these values that are constant here these these are static values it turns out
we don't need to store them either and so this entire hierarchy is actually purely determined by this one number parameter and that that ends up being the only value that we need to store but we can actually go further so we can generate code that understands that number
is the only thing that's going to change and so what we can say is well if number hasn't changed don't bother doing anything else just skip this invocation and the composer knows exactly how far to fast forward the execution to resume exactly where it needs to so the
final concept I want to talk about is sort of explaining how this recomposition happens that we talked about earlier so going back to this counter the the generated code that we would create for this counter has a composer start and end like we've seen and what I mentioned
earlier is that whenever we execute counter the runtime understands that when I call count dot value I'm reading the the property of of an app model instance and so at runtime what happens is whenever we call end we optionally return about and then we can call an update
scope method on that value basically with a lambda that tells the run time here's how to restart this composable if you need to and so this is sort of equivalent to that lambda that that live data would be receiving otherwise and the reason that this question mark is
here the reason that this is knowable is because if we don't actually read any model objects during the execution of of counter then there's actually no reason to teach the run time how to update this because we know it never will update okay so some some closing thoughts
I want to emphasize again that this is still really really early and more importantly that this isn't production-ready and we really mean that this is a really big undertaking and we're really still exploring a lot of these options and a lot of the things I just talked about
are pretty new kind of explorations on our side and so I think it's pretty interesting but just know that a lot of these things are still happening and we think there are some really really powerful ways that we can we can really gain some performance in in a
world where we have composable functions but we're still exploring that the other thing I want to point out is that everything we just talked about that's being done by the compiler is required for correctness and there's kind of an interesting oversight where code that uses at composable and
things like that compiles just fine with the old Kotlin compiler there are some blogs out on the internet that say hey you don't need Android studio 4 and all that they're wrong so what I ask is that you all be careful this was an oversight on our part
and we're exploring how to make this less of a foot bun but really your code is not correct unless you're using this compiler basically a new version of Android studio 4 or higher so just be very careful with that and then finally I want to give a give
a shout-out to the Kotlin link slack channel there's there's a compose channel in that community it's very active a lot of folks from from the compose team are on there including myself and if you do want to get involved and learn more about these things or even help
out that's a great place to start also another shout out we do a lot of UX studies around compose and the api's were choosing and we're really looking for more volunteers to help us out so upstairs at the compose sandbox there's a way to sign up if you're
interested and that's it thank you [Music]
Không có nhận xét nào:
Đăng nhận xét